instructure / paseto

A paseto implementation in rust.
MIT License
151 stars 14 forks source link

[FR] Invalidating generated tokens #59

Closed Sirneij closed 1 year ago

Sirneij commented 1 year ago

Is your feature request related to a problem? Please describe. I have a project I am currently using paseto for. I have implemented all other endpoints required for authentication and authorization but left with logging out users. But I can't seem to figure out how to invalidate tokens.

Describe the solution you'd like I want a method to invalidate the tokens generated.

Describe alternatives you've considered I have not tried out any other alternatives yet.

Additional context N/A

Mythra commented 1 year ago

Hi @Sirneij ! Thanks for the issue, I can say this repository is currently not actively maintained unfortunately. You'll want to head to one of the maintained libraries (which you can find at https://paseto.io). I personally recommend: https://github.com/brycx/pasetors (i think they did a great job and it's why I didn't update this one to v3/v4).

That being said I can help answer this, invalidation will need to be handled someway in your application. If your expirey mechanism is time only you can be aided with the helper functions in builder that let you specify expiry time or by manually specifying the exp claim. If you're using paseto claims with public keys of users you could rotate those keys. Or, finally you could have a custom claim has a field, that you can use to provide a key that you can use for expiration. I imagine the final one will be most useful!

Wishing you luck! Thanks!

Sirneij commented 1 year ago

The token generated has three fields: id, exp, and nbf. The tokens expire after 24 hours. My concern is how I can invalidate these tokens before expiry, like when a user logs out. How best can I do this. I built my token using the following snippet:

...
pub fn issue_token(id: Uuid) -> String {
    let current_date_time = Utc::now();
    let dt = current_date_time + chrono::Duration::days(1);
    paseto::tokens::PasetoBuilder::new()
        .set_encryption_key(&Vec::from(SETTINGS.secret.secret_key.as_bytes()))
        .set_expiration(&dt)
        .set_not_before(&Utc::now())
        .set_claim("id", serde_json::json!(id))
        .build()
        .expect("Failed to construct paseto token w/ builder!")
}
...

And I am verifying them using:

...
pub async fn verify_token(token: String) -> Result<Session, BackendError> {
    let token = paseto::tokens::validate_local_token(
        &token,
        None,
        &SETTINGS.secret.secret_key.as_bytes(),
        &paseto::tokens::TimeBackend::Chrono,
    )
    .map_err(|e| BackendError::CannotDecryptToken(format!("Paseto: {}", e)))?;

    serde_json::from_value::<Session>(token)
        .map_err(|e| BackendError::CannotDecryptToken(format!("Serde_json: {}", e)))
}
...
Mythra commented 1 year ago

Hi @Sirneij ,

As mentioned if you have some custom invalidation logic you'll want to introduce a custom claim that you can manage. The simplest possible way to do this would be just generating a random value that you store somewhere to validate if a session is live. It might also be worthwhile to while you're at it add in a field to invalidate "all" sessions if a user wishes to log out of all devices.

Now, where you store this doesn't really matter I'll write an example of just logging out a single device using pseudo-code with redis, but you don't have to use that. I recommend probably using whatever data store you already have to store user information. Although it is in the "hot-path", needing to be called everytime you'd like to validate a session, so maybe it'd be more worthwhile to use some data-store like redis that can cache these things really well. You'll also need to decide what to do when that data-store is down -- presumably fail so a user can always be sure when they "log out" and it succeeds cause the data store was up at one time, even if it goes down the user will still be "logged out", but your application may have it's own needs.

The newer version may look ~something like:

use hex;
use rand::{RngCore, OsRng};
use redis::{Commands, Connection};

/// Store the session key prefix as a const so it can't be typo'd anywhere it's used.
const SESSION_KEY_PREFIX: &str = "valid_session_key_for_{}";

pub fn issue_token(
  id: Uuid,
  redis_connection: &mut Connection,
) -> Result<String, BackendError> {
    // I just generate 128 bytes of random data for the session key
    // from something that is cryptographically secure (rand::CryptoRng)
    //
    // You don't necessarily need a random value, but you'll want something
    // that is sufficiently not able to be guessed (you don't want someone getting
    // an old token that is supposed to not be live, and being able to get a valid
    // token from that).
    let session_key: String = {
      let mut buff = [0_u8; 128];
      OsRng.fill_bytes(&mut buff);
      hex::encode(buff)
    };
    let _ = redis_connection.set(
      format!("{}{}", SESSION_KEY_PREFIX, session_key),
      // I just want to validate that the key exists to indicate the session is "live".
      String::new(),
      // "expirey" isn't needed for the key in the data store the token itself has it
      // but since redis has this feature built-in and we know a session can't last more
      // than 24 hours, why pay for storing it if it can't be used?
      //
      // plus it may help us if we ever forget to add an `exp` key to the token
      // itself due to a bug.
    ).exp(DAY_AS_SECONDS).map_err(BackendError::RedisError)?;
    let current_date_time = Utc::now();
    let dt = current_date_time + chrono::Duration::days(1);
    paseto::tokens::PasetoBuilder::new()
        .set_encryption_key(&Vec::from(SETTINGS.secret.secret_key.as_bytes()))
        .set_expiration(&dt)
        .set_not_before(&Utc::now())
        .set_claim("id", serde_json::json!(id))
        // We need the session key to validate the user hasn't logged out.
        .set_claim("session_key", serde_json::json!(session_key))
        .build()
        .map_err(BackendError::PasetoEncodeError)
}

Then on logout you would remove the key:

pub fn logout(
  session: Session,
  redis_connection: &mut Connection,
) -> Result<(), BackendError> {
   redis_connection
     .del(format!("{}{}", SESSION_KEY_PREFIX, session.session_key))
     .map_err(BackendError::RedisError)?;
   // ... whatever i would need to do for logging out.
}

Then in your "verify_token" path, validate that the user hasn't logged out by checking the key exists:

pub fn verify_token(token: String, redis_connection: &mut Connection) -> Result<Session, BackendError> {
    let token = paseto::tokens::validate_local_token(
        &token,
        None,
        &SETTINGS.secret.secret_key.as_bytes(),
        &paseto::tokens::TimeBackend::Chrono,
    )
    .map_err(|e| BackendError::CannotDecryptToken(format!("Paseto: {}", e)))?;
    let session = serde_json::from_value::<Session>(token)
        .map_err(|e| BackendError::CannotDecryptToken(format!("Serde_json: {}", e)))?;
    // Now we check that the key is still present!
    if redis_connection
      .get::<_, _, Option<String>>(
        format!("{}{}", SESSION_KEY_PREFIX, session.session_key)
      ).map_err(BackendError::RedisError)?.is_none() {
      return Err(BackendError::TokenLoggedOut);
    }
    return Ok(session);
}
Sirneij commented 1 year ago

Wow! Thank you. Let me check them out.

Sirneij commented 1 year ago

I finally added redis but as recommended, I opted for the more maintained crate, pasetors. The logic you suggested here was used heavily to invalidate tokens. Thank you.