Improve security
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
use std::future::{ready, Ready};
|
||||
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{error::ErrorUnauthorized, Error, FromRequest, HttpMessage, HttpRequest};
|
||||
|
||||
/// The user id of the caller, as established by the auth middleware after
|
||||
/// verifying the `user-token` header. Extracting this (instead of trusting a
|
||||
/// client-supplied `user_id` in the path/body) is the source of truth for
|
||||
/// "who is making this request".
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AuthUser(pub i32);
|
||||
|
||||
impl FromRequest for AuthUser {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||
match req.extensions().get::<AuthUser>() {
|
||||
Some(auth_user) => ready(Ok(*auth_user)),
|
||||
None => ready(Err(ErrorUnauthorized("missing authenticated user"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-11
@@ -2,17 +2,32 @@ extern crate hmac;
|
||||
extern crate jwt;
|
||||
extern crate sha2;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
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 = 24;
|
||||
|
||||
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>;
|
||||
@@ -25,11 +40,14 @@ fn signing_key() -> HmacSha256 {
|
||||
}
|
||||
|
||||
impl JwtToken {
|
||||
pub fn encode(user_id: i32) -> String {
|
||||
pub fn encode(user_id: i32, token_version: i32) -> String {
|
||||
let key: HmacSha256 = signing_key();
|
||||
let mut claims = BTreeMap::new();
|
||||
claims.insert("user_id", user_id);
|
||||
// Signing a simple map of claims with a valid HMAC key cannot fail.
|
||||
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")
|
||||
@@ -38,15 +56,18 @@ impl JwtToken {
|
||||
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, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
||||
let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
|
||||
VerifyWithKey::verify_with_key(token_str, &key);
|
||||
|
||||
match token {
|
||||
Ok(token) => {
|
||||
let _header = token.header();
|
||||
let claims = token.claims();
|
||||
if claims.exp < Utc::now().timestamp() {
|
||||
return Err("token has expired");
|
||||
}
|
||||
Ok(JwtToken {
|
||||
user_id: claims["user_id"],
|
||||
user_id: claims.user_id,
|
||||
token_version: claims.tv,
|
||||
})
|
||||
}
|
||||
Err(_err) => Err("could not decode token"),
|
||||
@@ -68,14 +89,19 @@ impl JwtToken {
|
||||
#[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::JwtToken;
|
||||
use super::{Claims, JwtToken};
|
||||
|
||||
#[test]
|
||||
async fn encode_decode() {
|
||||
let encoded_token: String = JwtToken::encode(32);
|
||||
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]
|
||||
@@ -88,9 +114,25 @@ mod jwt_test {
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
let encoded_token: String = JwtToken::encode(32, 0);
|
||||
let request = test::TestRequest::default()
|
||||
.insert_header(header::ContentType::json())
|
||||
.insert_header(("user-token", encoded_token))
|
||||
|
||||
+14
-8
@@ -1,12 +1,13 @@
|
||||
use actix_web::dev::ServiceRequest;
|
||||
pub mod extractor;
|
||||
pub mod jwt;
|
||||
pub mod processes;
|
||||
use crate::auth::processes::check_password;
|
||||
use crate::auth::processes::check_token;
|
||||
use crate::auth::processes::extract_header_token;
|
||||
|
||||
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
|
||||
match extract_header_token(request) {
|
||||
Ok(token) => check_password(token),
|
||||
Ok(token) => check_token(token),
|
||||
Err(message) => Err(message),
|
||||
}
|
||||
}
|
||||
@@ -16,19 +17,24 @@ mod mod_test {
|
||||
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
use super::{jwt::JwtToken, process_token};
|
||||
use super::process_token;
|
||||
use crate::auth::jwt::JwtToken;
|
||||
use crate::database::establish_connection;
|
||||
use crate::test_helpers::{delete_user, insert_user};
|
||||
|
||||
#[test]
|
||||
fn process_token_test() {
|
||||
let token = JwtToken::encode(32);
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
let token = JwtToken::encode(user.id, user.token_version);
|
||||
let request = TestRequest::delete()
|
||||
.insert_header(("user-token", token))
|
||||
.to_srv_request();
|
||||
|
||||
match process_token(&request) {
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
Err(_) => panic!("process token failed"),
|
||||
}
|
||||
assert_eq!(Ok(user.id), process_token(&request));
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
|
||||
+55
-24
@@ -1,22 +1,33 @@
|
||||
use super::jwt;
|
||||
use crate::database::establish_connection;
|
||||
use crate::models::user::rss_user::User;
|
||||
use crate::schema::users;
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use diesel::prelude::*;
|
||||
|
||||
pub fn check_password(password: String) -> Result<i32, &'static str> {
|
||||
match jwt::JwtToken::decode(password) {
|
||||
Ok(token) => Ok(token.user_id),
|
||||
Err(message) => Err(message),
|
||||
/// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
|
||||
/// still matches the one stored on the user (bumped on logout / password change).
|
||||
pub fn check_token(token: String) -> Result<i32, &'static str> {
|
||||
let decoded = jwt::JwtToken::decode(token)?;
|
||||
|
||||
let mut connection = establish_connection();
|
||||
let user: User = users::table
|
||||
.find(decoded.user_id)
|
||||
.first(&mut connection)
|
||||
.map_err(|_| "could not decode token")?;
|
||||
|
||||
if user.token_version != decoded.token_version {
|
||||
return Err("token has been revoked");
|
||||
}
|
||||
|
||||
Ok(decoded.user_id)
|
||||
}
|
||||
|
||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||
log::info!("Request: {:?}", request);
|
||||
match request.headers().get("user-token") {
|
||||
Some(token) => match token.to_str() {
|
||||
Ok(processed_password) => {
|
||||
log::info!("Token provided: {}", processed_password);
|
||||
Ok(String::from(processed_password))
|
||||
}
|
||||
Err(_processed_password) => Err("there was an error processing token"),
|
||||
Ok(processed_token) => Ok(String::from(processed_token)),
|
||||
Err(_) => Err("there was an error processing token"),
|
||||
},
|
||||
None => Err("there is no token"),
|
||||
}
|
||||
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
|
||||
#[cfg(test)]
|
||||
mod processes_test {
|
||||
use actix_web::test::TestRequest;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::auth::jwt::JwtToken;
|
||||
use crate::database::establish_connection;
|
||||
use crate::test_helpers::{delete_user, insert_user};
|
||||
|
||||
use super::check_password;
|
||||
use super::check_token;
|
||||
|
||||
#[test]
|
||||
fn check_correct_password() {
|
||||
let password_string: String = JwtToken::encode(32);
|
||||
fn check_correct_token() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
let result = check_password(password_string);
|
||||
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||
|
||||
match result {
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
_ => panic!("Check correct password failed."),
|
||||
}
|
||||
let result = check_token(token);
|
||||
|
||||
assert_eq!(Ok(user.id), result);
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incorrect_check_password() {
|
||||
let password: String = String::from("test");
|
||||
fn revoked_token_is_rejected() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
match check_password(password) {
|
||||
Err(message) => assert_eq!("could not decode token", message),
|
||||
_ => panic!("check password should not be able to be decoded"),
|
||||
}
|
||||
// Token signed with the user's current version, then the version is bumped
|
||||
// (as logout would do), which must invalidate the previously issued token.
|
||||
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||
diesel::update(crate::schema::users::table.find(user.id))
|
||||
.set(crate::schema::users::token_version.eq(user.token_version + 1))
|
||||
.execute(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(Err("token has been revoked"), check_token(token));
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incorrect_check_token() {
|
||||
let token: String = String::from("test");
|
||||
|
||||
assert_eq!(Err("could not decode token"), check_token(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user