Skip to content

Commit

Permalink
Sign JWTs with the user's password hash
Browse files Browse the repository at this point in the history
Instead of hardcoding the JWT signing secret in the binary, use each
user's password hash as the secret. When the user changes password, the
secret will automatically change, thus automatically revoking all
existing logins.

In addition, these hashes are less guessable (than using a hardcoded
string or even a single secret for the entire app), since they are
cryptographically secure random strings and different for every user.

To validate the token, we now need to fetch the user from the ID stored
in the token itself. To make this easier, store the user ID in the token
header under the key "kid".
  • Loading branch information
Aleksbgbg committed Feb 17, 2024
1 parent b5b9390 commit 0cf3397
Show file tree
Hide file tree
Showing 3 changed files with 66 additions and 18 deletions.
13 changes: 7 additions & 6 deletions backend-rs/backend/src/controllers/errors.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::controllers::user::AuthError;
use crate::models::user;
use axum::extract::rejection::JsonRejection;
use axum::extract::{FromRequest, Request};
Expand Down Expand Up @@ -63,11 +64,11 @@ pub enum HandlerError {
EmailTaken,
#[error("Login credentials are invalid.")]
InvalidCredentials,
#[error("Authentication token is invalid.")]
DecodeJwt(jsonwebtoken::errors::Error),
#[error("User cannot be logged into.")]
ImpossibleLogin,

#[error("No user was logged in but a user is required.")]
UserRequired,
#[error("No user was logged in but a user is required: {0}.")]
UserRequired(#[from] AuthError),

#[error("Database transaction failed.")]
Database(#[from] DbErr),
Expand Down Expand Up @@ -133,9 +134,9 @@ impl IntoResponse for HandlerError {
HandlerError::UsernameTaken => self.failed_validation(StatusCode::BAD_REQUEST, "username"),
HandlerError::EmailTaken => self.failed_validation(StatusCode::BAD_REQUEST, "emailAddress"),
HandlerError::InvalidCredentials => self.into_generic(StatusCode::BAD_REQUEST),
HandlerError::DecodeJwt(_) => self.into_generic(StatusCode::BAD_REQUEST),
HandlerError::ImpossibleLogin => self.into_generic(StatusCode::BAD_REQUEST),

HandlerError::UserRequired => self.into_generic(StatusCode::UNAUTHORIZED),
HandlerError::UserRequired(_) => self.into_generic(StatusCode::UNAUTHORIZED),

HandlerError::Database(_) => self.into_generic(StatusCode::INTERNAL_SERVER_ERROR),
HandlerError::CreateUser(_) => self.into_generic(StatusCode::INTERNAL_SERVER_ERROR),
Expand Down
62 changes: 50 additions & 12 deletions backend-rs/backend/src/controllers/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
use validator::Validate;

const AUTH_COOKIE_KEY: &str = "Authorization";
const SECRET: &str = "123456";

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
uid: Id,
exp: usize,
}

Expand All @@ -29,12 +29,20 @@ fn authenticate(
let cookie = Cookie::build((
AUTH_COOKIE_KEY,
jsonwebtoken::encode(
&Header::default(),
&Header {
kid: Some(user.id.to_string()),
..Default::default()
},
&Claims {
uid: user.id,
exp: (Utc::now() + lifespan).timestamp().try_into().unwrap(),
},
&EncodingKey::from_secret(SECRET.as_ref()),
&EncodingKey::from_secret(
user
.password
.as_ref()
.ok_or(HandlerError::ImpossibleLogin)?
.as_bytes(),
),
)
.map_err(HandlerError::EncodeJwt)?,
))
Expand Down Expand Up @@ -109,6 +117,22 @@ pub async fn login(
}
}

#[derive(Error, Debug)]
pub enum AuthError {
#[error("authorization cookie not present")]
NoAuthCookie,
#[error("could not decode header")]
DecodeHeader(jsonwebtoken::errors::Error),
#[error("user ID was not present")]
NoKid,
#[error("could not decode user ID")]
DecodeId(bs58::decode::Error),
#[error("user ID was not found")]
UserNotFound,
#[error("could not decode authentication token")]
DecodeJwt(jsonwebtoken::errors::Error),
}

#[async_trait]
impl FromRequestParts<AppState> for User {
type Rejection = HandlerError;
Expand All @@ -120,19 +144,33 @@ impl FromRequestParts<AppState> for User {
let cookies = parts.extract::<CookieJar>().await.unwrap();
let token = cookies
.get(AUTH_COOKIE_KEY)
.ok_or(HandlerError::UserRequired)?
.ok_or(AuthError::NoAuthCookie)?
.value();
let claims = jsonwebtoken::decode::<Claims>(
let id = jsonwebtoken::decode_header(token)
.map_err(AuthError::DecodeHeader)?
.kid
.ok_or(AuthError::NoKid)?;
let user = user::find(
&state.connection,
Id::from_str(&id).map_err(AuthError::DecodeId)?,
)
.await?
.ok_or(AuthError::UserNotFound)?;
let _claims = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(SECRET.as_ref()),
&DecodingKey::from_secret(
user
.password
.as_ref()
.ok_or(HandlerError::ImpossibleLogin)?
.as_bytes(),
),
&Validation::default(),
)
.map_err(HandlerError::DecodeJwt)?
.map_err(AuthError::DecodeJwt)?
.claims;

user::find(&state.connection, claims.uid)
.await?
.ok_or(HandlerError::UserRequired)
Ok(user)
}
}

Expand Down
9 changes: 9 additions & 0 deletions backend-rs/backend/src/models/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use sea_orm::{DbErr, DeriveValueType, TryFromU64};
use serde::de::{Unexpected, Visitor};
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::str::FromStr;

#[derive(Clone, Copy, Debug, PartialEq, Eq, DeriveValueType)]
pub struct Id(i64);
Expand Down Expand Up @@ -48,6 +49,14 @@ impl Display for Id {
}
}

impl FromStr for Id {
type Err = bs58::decode::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
decode(s)
}
}

impl Serialize for Id {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
Expand Down

0 comments on commit 0cf3397

Please sign in to comment.