161 lines
5.1 KiB
Rust
Executable File
161 lines
5.1 KiB
Rust
Executable File
extern crate hmac;
|
|
extern crate jwt;
|
|
extern crate sha2;
|
|
|
|
use std::env;
|
|
|
|
use actix_web::HttpRequest;
|
|
use chrono::{Duration, Utc};
|
|
use dotenv::dotenv;
|
|
use hmac::{Hmac, Mac};
|
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::Sha256;
|
|
|
|
/// How long a freshly issued token remains valid for.
|
|
const TOKEN_LIFETIME_HOURS: i64 = 730;
|
|
|
|
pub struct JwtToken {
|
|
pub user_id: i32,
|
|
pub token_version: i32,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct Claims {
|
|
user_id: i32,
|
|
/// Must match `users.token_version` for the token to be accepted; bumping
|
|
/// the column (e.g. on logout) revokes every token issued before that point.
|
|
tv: i32,
|
|
/// Unix timestamp after which the token is rejected, independent of signature validity.
|
|
exp: i64,
|
|
}
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
fn signing_key() -> HmacSha256 {
|
|
dotenv().ok();
|
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
|
// HMAC-SHA256 accepts a key of any length, so this cannot fail.
|
|
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
|
|
}
|
|
|
|
impl JwtToken {
|
|
pub fn encode(user_id: i32, token_version: i32) -> String {
|
|
let key: HmacSha256 = signing_key();
|
|
let claims = Claims {
|
|
user_id,
|
|
tv: token_version,
|
|
exp: (Utc::now() + Duration::hours(TOKEN_LIFETIME_HOURS)).timestamp(),
|
|
};
|
|
// Signing claims with a valid HMAC key cannot fail.
|
|
claims
|
|
.sign_with_key(&key)
|
|
.expect("signing claims with a valid HMAC key cannot fail")
|
|
}
|
|
|
|
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
|
let key: HmacSha256 = signing_key();
|
|
let token_str: &str = encoded_token.as_str();
|
|
let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
|
|
VerifyWithKey::verify_with_key(token_str, &key);
|
|
|
|
match token {
|
|
Ok(token) => {
|
|
let claims = token.claims();
|
|
if claims.exp < Utc::now().timestamp() {
|
|
return Err("token has expired");
|
|
}
|
|
Ok(JwtToken {
|
|
user_id: claims.user_id,
|
|
token_version: claims.tv,
|
|
})
|
|
}
|
|
Err(_err) => Err("could not decode token"),
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
|
match request.headers().get("user-token") {
|
|
Some(token) => match token.to_str() {
|
|
Ok(token_str) => JwtToken::decode(String::from(token_str)),
|
|
Err(_) => Err("token header is not valid text"),
|
|
},
|
|
None => Err("There is no token"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod jwt_test {
|
|
use actix_web::{http::header, test};
|
|
use chrono::{Duration, Utc};
|
|
use hmac::Hmac;
|
|
use jwt::SignWithKey;
|
|
use sha2::Sha256;
|
|
|
|
use super::{Claims, JwtToken};
|
|
|
|
#[test]
|
|
async fn encode_decode() {
|
|
let encoded_token: String = JwtToken::encode(32, 0);
|
|
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
|
assert_eq!(32, decoded_token.user_id);
|
|
assert_eq!(0, decoded_token.token_version);
|
|
}
|
|
|
|
#[test]
|
|
async fn decode_incorrect_token() {
|
|
let encoded_token: String = String::from("test");
|
|
|
|
match JwtToken::decode(encoded_token) {
|
|
Err(message) => assert_eq!(message, "could not decode token"),
|
|
_ => panic!("Incorrect token should not be able to decode."),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
async fn decode_expired_token() {
|
|
let key: Hmac<Sha256> = super::signing_key();
|
|
let claims = Claims {
|
|
user_id: 32,
|
|
tv: 0,
|
|
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
|
};
|
|
let expired_token: String = claims.sign_with_key(&key).unwrap();
|
|
|
|
match JwtToken::decode(expired_token) {
|
|
Err(message) => assert_eq!(message, "token has expired"),
|
|
_ => panic!("Expired token should not be accepted."),
|
|
}
|
|
}
|
|
|
|
#[actix_web::test]
|
|
async fn decode_from_request_with_correct_token() {
|
|
let encoded_token: String = JwtToken::encode(32, 0);
|
|
let request = test::TestRequest::default()
|
|
.insert_header(header::ContentType::json())
|
|
.insert_header(("user-token", encoded_token))
|
|
.to_http_request();
|
|
let out_come = JwtToken::decode_from_request(request);
|
|
|
|
match out_come {
|
|
Ok(token) => assert_eq!(32, token.user_id),
|
|
_ => panic!("Token is not returned with it should be."),
|
|
}
|
|
}
|
|
|
|
#[actix_web::test]
|
|
async fn decode_from_request_with_no_token() {
|
|
let request = test::TestRequest::default()
|
|
.insert_header(("test", "test"))
|
|
.to_http_request();
|
|
let out_come = JwtToken::decode_from_request(request);
|
|
|
|
match out_come {
|
|
Err(message) => assert_eq!("There is no token", message),
|
|
_ => panic!("Token should not be returned when it is not present in the header."),
|
|
}
|
|
}
|
|
}
|