Plumber token authentication

HTTP API
Author

Shaun Nielsen

Published

February 2, 2023

I’m working on a project that involves using a server to take in user data to run a bioinformatics pipeline with R and blast+. I am using plumber as the HTTP API to receive the user data and send data to background R processes. To add a layer of (clandestine) security, I tried making a token authentication system.

Background

I have worked with HTTP APIs many times before - from free to highly secure. One involved POSTing user credentials + a key in order to receive a time-expiring token for downstream use. I had a go at creating something similar using plumber.

Now, there is this thing called a RESTful API (google it!) which is a set of architectural constraints. Ideally we want our API to follow these constraints. This following API voids the stateless criteria, as we keep a DB of information. But that is ok here.

The logic

  1. A plumber HTTP API is up and running (‘the API’)
  2. Users can request a token by POSTing credentials. The API validates the credentials using a local DB*. If valid, it creates a token (a value of 24 random “0:9, letter, LETTERS”) and token_expiry time (time + X time, here 10 seconds). It updates the DB with token and token_expiry of the user, then returns a response with the header ‘token’ and token value.
  • the DB contains user credentials, tokens and token expiry times
  1. Another request is made .. it must include the header ‘token’ with the token value
  2. The API checks the request for a header ‘token’, and if present, compares it to the DB
  3. If the token is found, the expiry time is checked, and if valid, the user request is carried out
  4. Where any of the validation steps fail, a response is sent with various 400 status codes.
  • Having a DB means this thing is not stateless .. there are other token options, but more exploratory time is required by myself.

The setup

The API itself required plumber, dplyr and RSQLite. I used dplyr as it simplified some SQL code (which could otherwise be properly written as SQL statements). For development, I used httr2 for interacting with the API and callr to run the API within the same RStudio session.

User DB

A simple local SQL DB.

users <- RSQLite::dbConnect(RSQLite::SQLite(), "users.sql")

users_values <-
  tibble::tribble(
    ~name, ~user, ~password, ~token, ~token_expiry,
    'John','jbrown','1234','','',
    'Sally','sblue', '4321','',''
  )

RSQLite::dbWriteTable(users, name = 'users', value = users_values)
RSQLite::dbDisconnect(users)

Plumber script

library(plumber)
suppressPackageStartupMessages(library(dplyr))
library(RSQLite)

#* @apiTitle Plumber Example API for token authentication
#* @apiDescription A simple example of creating and verifying a token. It uses
#* a local DB, plumber filters, and returns user data if token verified.

#* API setup
#*
#* Change default serializer
#*
#* @plumber
function(pr){
  pr %>%
    pr_set_serializer(serializer_unboxed_json())
}

#* @filter token_check
#*
#* Check that a token is provided and is valid for carrying out request. This
#* filter is run for every request unless a `@preempt token_check` is used.
#*
#* @param req,res plumber request and response objects
function(req, res) {

  if (is.null(req$HTTP_TOKEN)){
    res$status <- 401
    return(list(error = 'token not found in request header'))
  }

  users <- RSQLite::dbConnect(RSQLite::SQLite(), "users.sql")

  req_token = req$HTTP_TOKEN

  token_row <-
    dplyr::tbl(users, 'users') %>%
    dplyr::filter(token == req_token) %>%
    dplyr::collect()

  if (nrow(token_row) == 0){
    res$status <- 401
    return(list(error = 'token not allocated to user'))
  }

  token_expired <- as.numeric(token_row$token_expiry) - as.numeric(Sys.time()) < 0

  if (is.na(token_expired) || token_expired) {
    res$status <- 401
    return(list(error = 'token expired, please refesh token'))
  }

  plumber::forward()

}

#* Refresh user token
#*
#* Return token in HTTP header 'token'. This function excludes the `token_check`
#* filter.
#*
#* @param req,res plumber request and response objects
#*
#* Expects a request body with `user` and `password`
#*
#* @preempt token_check
#* @post /refresh-token
function(req, res) {

  any_missing_credentials <- any( is.null(req$body$user) | is.null(req$body$password) )

  if (any_missing_credentials){
    res$status <- 400
    return(list(error = 'user or password not included in request body'))
  }

  users <- RSQLite::dbConnect(RSQLite::SQLite(), "users.sql")

  req_user <- req$body$user

  user_row <-
    dplyr::tbl(users, 'users') %>%
    dplyr::filter(user == req_user) %>%
    dplyr::collect()

  if (nrow(user_row) == 0){
    res$status <- 401
    return(list(error = 'user not found'))
  }

  password_incorrect <- req$body$password != user_row$password

  if (password_incorrect){
    res$status <- 401
    return(list(error = 'password incorrect'))
  }

  token <- paste(sample(c(0:9, letters, LETTERS), size = 24, replace = TRUE), collapse = '')

  # A 10 second expiry time
  token_expiry <- Sys.time() + 10

  RSQLite::dbExecute(users, "UPDATE users SET token = ?, token_expiry = ? where user = ? and password = ?",
                     params = c(token, token_expiry, user_row$user, user_row$password))

  RSQLite::dbDisconnect(users)

  res$setHeader('token', token)

}

#* A simple function to return user data in DB
#*
#* This endpoint will only be reached if a user supplies a valid token
#*
#* @param req,res plumber request and response objects
#*
#* @get /return-data
function(req, res) {

  users <- RSQLite::dbConnect(RSQLite::SQLite(), "users.sql")

  req_token = req$HTTP_TOKEN

  token_row <-
    dplyr::tbl(users, 'users') %>%
    dplyr::filter(token == req_token) %>%
    dplyr::collect()

  if (nrow(token_row) == 0){
    res$status <- 500
    return(list(error = 'token not allocated to user'))
  }

  return(as.list(token_row))
}

Testing

I used callr to create a background process with the API running

rp <- 
  callr::r_bg(function(){ 
    plumber::pr_run(plumber::pr('token-plumber-api.R'), port = 8989)
  })
rp$is_alive()
[1] TRUE
cat(rp$read_error())
Running plumber API at http://127.0.0.1:8989
Running swagger Docs at http://127.0.0.1:8989/__docs__/

Then query the API using httr2

library(httr2)

requrl <- httr2::request('http://127.0.0.1:8989')

The endpoint /return-data will be reached after the request goes through the token_check filter. Here, no token is provided. Note that if an error is sent by the server to the client, httr2 will by default throw an R error and we do not want that here hence the req_error line - we would otherwise not be able to see what the error message sent was.

# The response
resp_no_token <-
  requrl %>% 
  req_url_path_append('return-data') %>% 
  req_error(is_error = function(res) FALSE) %>% 
  req_perform() %>% 
  print()
<httr2_response>
GET http://127.0.0.1:8989/return-data
Status: 401 Unauthorized
Content-Type: application/json
Body: In memory (45 bytes)
# The response message
resp_no_token %>% 
  resp_body_json() %>% 
  print()
$error
[1] "token not found in request header"

Thus a user needs to submit their credentials to the end point /refresh-token to receive a (time-limited) token. Remember this endpoint does not go through the token_check filter due to the @preempt directive used. We then extract the value of the header token

token <-
  requrl %>% 
  req_url_path_append('refresh-token') %>% 
  req_body_json(
    list(user = 'jbrown',
         password = '1234')
  ) %>% 
  req_perform() %>% 
  resp_header('token') %>% 
  print()
[1] "8dP3V3DQIvSTBmHcda9Ysnyl"

And then include it in future requests

resp_with_token <-
  requrl %>% 
  req_url_path_append('return-data') %>% 
  req_headers(token = token) %>% 
  req_error(is_error = function(res) FALSE) %>% 
  req_perform() %>% 
  print()
<httr2_response>
GET http://127.0.0.1:8989/return-data
Status: 200 OK
Content-Type: application/json
Body: In memory (118 bytes)
resp_with_token %>% 
  resp_body_json() %>% 
  print()
$name
[1] "John"

$user
[1] "jbrown"

$password
[1] "1234"

$token
[1] "8dP3V3DQIvSTBmHcda9Ysnyl"

$token_expiry
[1] "1721890161.48539"

Since the token is time-limited (10 seconds here), what if we wait 12 seconds and try again?

Sys.sleep(12)
resp_with_token <-
  requrl %>% 
  req_url_path_append('return-data') %>% 
  req_headers(token = token) %>% 
  req_error(is_error = function(res) FALSE) %>% 
  req_perform() %>% 
  print()
<httr2_response>
GET http://127.0.0.1:8989/return-data
Status: 401 Unauthorized
Content-Type: application/json
Body: In memory (46 bytes)
resp_with_token %>% 
  resp_body_json() %>% 
  print()
$error
[1] "token expired, please refesh token"

A call to /refresh-token is required to move forward …

Kill background R process (the API)

rp$kill()
[1] TRUE

Conclusion

Here we explored a HTTP API using plumber and attempted to create an authentication system. It involved using API filters and endpoints, sending and receiving HTTP headers, sending HTTP response codes, and using a local SQL database for credential storage.

Obviously there are better ways to handle security (type of token, where does token go - in cookies?, encrypted cookies), or do what we did above is a better way (ensure credential DB is more secure - set permissions on the server) but we have a simple working API with a security layer happening.