Skip to content

Commit d433b35

Browse files
committedJun 28, 2023
Switch to better JWT crate
JWT -> jsonwebtoken
1 parent d782713 commit d433b35

9 files changed

+141
-68
lines changed
 

‎Cargo.lock

+30-11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+2-3
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ edition = "2021"
77
async-graphql = "5.0.10"
88
async-graphql-axum = "5.0.10"
99
axum = "0.6"
10-
hmac = "0.12"
11-
jwt = "0.16"
10+
chrono = "0.4"
11+
jsonwebtoken = "8.3"
1212
serde = { version = "1", features = ["derive"] }
1313
serde_json = "1"
14-
sha2 = "0.10"
1514
surrealdb = { git = "https://github.com/surrealdb/surrealdb.git", tag = "v1.0.0-beta.9", features = [
1615
"kv-mem"
1716
] }

‎README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ To use routes other than `/hello`, login with:
2424
## Features implemented
2525

2626
- Axum
27-
- JWT, login with cookies
2827
- Query and Path get examples
2928
- REST CRUD with in-memory mock model
29+
- JWT
30+
- login with expiration
31+
- saved in cookies HttpOnly
3032
- Manual Error handling without 3rd party crates
3133
- Errors respond with request IDs
3234
- Debug and Display variants for server and client

‎assets/logo.png

-18.6 KB
Loading

‎cspell-words.txt

+2
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ graphiql
44
surrealdb
55
hmac
66
Hmac
7+
jsonwebtoken
8+
chrono

‎src/error.rs

+4-7
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ pub enum Error {
2020
TicketDeleteFailIdNotFound { id: u64 },
2121
AuthFailNoJwtCookie,
2222
AuthFailJwtInvalid { source: String },
23-
AuthFailJwtWithoutAuth,
2423
AuthFailCtxNotInRequestExt,
2524
Serde { source: String },
2625
SurrealDb { source: String },
@@ -64,8 +63,8 @@ impl fmt::Display for Error {
6463
Self::LoginFail => write!(f, "Login fail"),
6564
Self::TicketDeleteFailIdNotFound { id } => write!(f, "Ticket id {id} not found"),
6665
Self::AuthFailNoJwtCookie => write!(f, "You are not logged in"),
67-
Self::AuthFailJwtInvalid { .. } | Self::AuthFailJwtWithoutAuth => {
68-
write!(f, "Can't parse token, wrong format")
66+
Self::AuthFailJwtInvalid { .. } => {
67+
write!(f, "The provided JWT token is not valid")
6968
}
7069
Self::Serde { source } => write!(f, "Serde error - {source}"),
7170
Self::AuthFailCtxNotInRequestExt => write!(f, "{INTERNAL}"),
@@ -90,7 +89,6 @@ impl IntoResponse for ApiError {
9089
| Error::AuthFailNoJwtCookie
9190
| Error::AuthFailJwtInvalid { .. }
9291
| Error::AuthFailCtxNotInRequestExt
93-
| Error::AuthFailJwtWithoutAuth
9492
| Error::SurrealDb { .. } => StatusCode::FORBIDDEN,
9593
};
9694
let body = Json(json!({
@@ -144,9 +142,8 @@ impl From<surrealdb::error::Db> for Error {
144142
}
145143
}
146144

147-
impl From<jwt::error::Error> for Error {
148-
fn from(value: jwt::error::Error) -> Self {
149-
// TODO: handle better - tell on expiration etc
145+
impl From<jsonwebtoken::errors::Error> for Error {
146+
fn from(value: jsonwebtoken::errors::Error) -> Self {
150147
Self::AuthFailJwtInvalid {
151148
source: value.to_string(),
152149
}

‎src/main.rs

+7-6
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ use error::{ApiResult, Result};
1717
use graphql::{
1818
graphiql, graphql_handler, mutation_root::MutationRoot, query_root::QueryRoot, ApiSchema,
1919
};
20-
use hmac::{Hmac, Mac};
20+
use jsonwebtoken::{DecodingKey, EncodingKey};
2121
use mw_ctx::CtxState;
2222
use mw_req_logger::mw_req_logger;
2323
use service::ticket_no_db::ModelController;
24-
use sha2::Sha256;
2524
use std::net::{Ipv4Addr, SocketAddr};
2625
use surrealdb::{
2726
engine::local::{Db as LocalDb, Mem},
@@ -68,12 +67,14 @@ async fn main() -> Result<()> {
6867
let routes_tickets = web::routes_tickets::routes(DB.clone())
6968
.route_layer(middleware::from_fn(mw_ctx::mw_require_auth));
7069

71-
// Load salt and create secret key for JWT
72-
let salt = "some-secret".as_bytes();
73-
let key: Hmac<Sha256> = Hmac::new_from_slice(salt).unwrap();
70+
// Load secret and create secret key for JWT
71+
let secret = "some-secret".as_bytes();
72+
let key_enc = EncodingKey::from_secret(secret);
73+
let key_dec = DecodingKey::from_secret(secret);
7474
let ctx_state = CtxState {
7575
_db: DB.clone(),
76-
key,
76+
key_enc,
77+
key_dec,
7778
};
7879

7980
// Main router

‎src/mw_ctx.rs

+80-32
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
use crate::{ctx::Ctx, error::Error, error::Result, ApiResult, Db};
22
use axum::{extract::State, http::Request, middleware::Next, response::Response};
3-
use hmac::Hmac;
4-
use jwt::VerifyWithKey;
5-
use sha2::Sha256;
6-
use std::collections::BTreeMap;
3+
use jsonwebtoken::{decode, DecodingKey, EncodingKey, Validation};
4+
use serde::{Deserialize, Serialize};
75
use tower_cookies::{Cookie, Cookies};
86
use uuid::Uuid;
97

108
#[derive(Clone)]
119
pub struct CtxState {
1210
// NOTE: with DB, because a real login would check the DB
1311
pub _db: Db,
14-
pub key: Hmac<Sha256>,
12+
pub key_enc: EncodingKey,
13+
pub key_dec: DecodingKey,
1514
}
1615

1716
pub const JWT_KEY: &str = "jwt";
18-
pub const JWT_AUTH: &str = "auth";
17+
#[derive(Debug, Serialize, Deserialize)]
18+
pub struct Claims {
19+
pub exp: usize,
20+
pub auth: String,
21+
}
1922

2023
pub async fn mw_require_auth<B>(ctx: Ctx, req: Request<B>, next: Next<B>) -> ApiResult<Response> {
2124
println!("->> {:<12} - mw_require_auth - {ctx:?}", "MIDDLEWARE");
@@ -24,15 +27,15 @@ pub async fn mw_require_auth<B>(ctx: Ctx, req: Request<B>, next: Next<B>) -> Api
2427
}
2528

2629
pub async fn mw_ctx_constructor<B>(
27-
State(CtxState { _db, key }): State<CtxState>,
30+
State(CtxState { _db, key_dec, .. }): State<CtxState>,
2831
cookies: Cookies,
2932
mut req: Request<B>,
3033
next: Next<B>,
3134
) -> Response {
3235
println!("->> {:<12} - mw_ctx_constructor", "MIDDLEWARE");
3336

3437
let uuid = Uuid::new_v4();
35-
let result_user_id: Result<String> = extract_token(key, &cookies).map_err(|err| {
38+
let result_user_id: Result<String> = extract_token(key_dec, &cookies).map_err(|err| {
3639
// Remove an invalid cookie
3740
if let Error::AuthFailJwtInvalid { .. } = err {
3841
cookies.remove(Cookie::named(JWT_KEY))
@@ -48,14 +51,12 @@ pub async fn mw_ctx_constructor<B>(
4851
next.run(req).await
4952
}
5053

51-
fn verify_token(key: Hmac<Sha256>, token: &str) -> Result<String> {
52-
let claims: BTreeMap<String, String> = token.verify_with_key(&key)?;
53-
claims
54-
.get(JWT_AUTH)
55-
.ok_or(Error::AuthFailJwtWithoutAuth)
56-
.map(String::from)
54+
fn verify_token(key: DecodingKey, token: &str) -> Result<String> {
55+
Ok(decode::<Claims>(token, &key, &Validation::default())?
56+
.claims
57+
.auth)
5758
}
58-
fn extract_token(key: Hmac<Sha256>, cookies: &Cookies) -> Result<String> {
59+
fn extract_token(key: DecodingKey, cookies: &Cookies) -> Result<String> {
5960
cookies
6061
.get(JWT_KEY)
6162
.ok_or(Error::AuthFailNoJwtCookie)
@@ -64,31 +65,78 @@ fn extract_token(key: Hmac<Sha256>, cookies: &Cookies) -> Result<String> {
6465

6566
#[cfg(test)]
6667
mod tests {
67-
use crate::mw_ctx::JWT_AUTH;
68-
use hmac::{Hmac, Mac};
69-
use jwt::SignWithKey;
70-
use sha2::Sha256;
71-
use std::collections::BTreeMap;
68+
use crate::mw_ctx::Claims;
69+
use chrono::{Duration, Utc};
70+
use jsonwebtoken::{
71+
decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation,
72+
};
7273

7374
const SECRET: &[u8] = b"some-secret";
7475
const SOMEONE: &str = "someone";
75-
const TOKEN: &str =
7676
// cspell:disable-next-line
77-
"eyJhbGciOiJIUzI1NiJ9.eyJhdXRoIjoic29tZW9uZSJ9.1g78DkCARXRPLRlRbzv_nKZZuykVr5_nwPaifpVTvvM";
77+
const TOKEN_EXPIRED: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsImF1dGgiOiJzb21lb25lIn0.XXHVHu2IsUPA175aQ-noWbQK4Wu-2prk3qTXjwaWBvE";
78+
79+
#[test]
80+
fn jwt_sign_expired() {
81+
let my_claims = Claims {
82+
exp: 1,
83+
auth: SOMEONE.to_string(),
84+
};
85+
let token_str = encode(
86+
&Header::default(),
87+
&my_claims,
88+
&EncodingKey::from_secret(SECRET),
89+
)
90+
.unwrap();
91+
assert_eq!(token_str, TOKEN_EXPIRED);
92+
}
93+
94+
#[test]
95+
fn jwt_verify_expired_ignore() {
96+
let mut validation = Validation::default();
97+
validation.validate_exp = false;
98+
let token = decode::<Claims>(
99+
TOKEN_EXPIRED,
100+
&DecodingKey::from_secret(SECRET),
101+
&validation,
102+
)
103+
.unwrap();
104+
assert_eq!(token.claims.auth, SOMEONE);
105+
}
78106

79107
#[test]
80-
fn jwt_sign() {
81-
let key: Hmac<Sha256> = Hmac::new_from_slice(SECRET).unwrap();
82-
let mut claims = BTreeMap::new();
83-
claims.insert(JWT_AUTH, SOMEONE);
84-
let token_str = claims.sign_with_key(&key).unwrap();
85-
assert_eq!(token_str, TOKEN);
108+
fn jwt_verify_expired_fail() {
109+
let token_result = decode::<Claims>(
110+
TOKEN_EXPIRED,
111+
&DecodingKey::from_secret(SECRET),
112+
&Validation::default(),
113+
);
114+
assert!(token_result.is_err());
115+
let kind = token_result.map_err(|e| e.into_kind()).err();
116+
assert_eq!(kind, Some(ErrorKind::ExpiredSignature));
86117
}
87118

88119
#[test]
89-
fn jwt_verify() {
90-
let key: Hmac<Sha256> = Hmac::new_from_slice(SECRET).unwrap();
91-
let user_id = super::verify_token(key, TOKEN).unwrap();
92-
assert_eq!(user_id, SOMEONE);
120+
fn jwt_sign_and_verify_with_chrono() {
121+
let exp = Utc::now() + Duration::minutes(1);
122+
let my_claims = Claims {
123+
exp: exp.timestamp() as usize,
124+
auth: SOMEONE.to_string(),
125+
};
126+
// Sign
127+
let token_str = encode(
128+
&Header::default(),
129+
&my_claims,
130+
&EncodingKey::from_secret(SECRET),
131+
)
132+
.unwrap();
133+
// Verify
134+
let token_result = decode::<Claims>(
135+
&token_str,
136+
&DecodingKey::from_secret(SECRET),
137+
&Validation::default(),
138+
)
139+
.unwrap();
140+
assert_eq!(token_result.claims.auth, SOMEONE);
93141
}
94142
}

‎src/web/routes_login.rs

+13-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
use crate::mw_ctx::{CtxState, JWT_AUTH, JWT_KEY};
1+
use crate::mw_ctx::{Claims, CtxState, JWT_KEY};
22
use crate::{ctx::Ctx, error::ApiError, error::Error, ApiResult};
33
use axum::extract::State;
44
use axum::{routing::post, Json, Router};
5-
use jwt::SignWithKey;
5+
use chrono::{Duration, Utc};
6+
use jsonwebtoken::{encode, Header};
67
use serde::{Deserialize, Serialize};
7-
use std::collections::BTreeMap;
88
use tower_cookies::{Cookie, Cookies};
99

1010
pub fn routes(state: CtxState) -> Router {
@@ -28,7 +28,7 @@ struct LoginResult {
2828
}
2929

3030
async fn api_login(
31-
State(CtxState { _db, key }): State<CtxState>,
31+
State(CtxState { _db, key_enc, .. }): State<CtxState>,
3232
cookies: Cookies,
3333
ctx: Ctx,
3434
payload: Json<LoginInput>,
@@ -53,15 +53,20 @@ async fn api_login(
5353
});
5454
};
5555

56-
let mut claims = BTreeMap::new();
57-
claims.insert(JWT_AUTH, mock_user.email);
58-
// TODO: don't know how to set expiration
59-
let token_str = claims.sign_with_key(&key).unwrap();
56+
// NOTE: set to a reasonable number after testing
57+
// NOTE when testing: the default validation.leeway is 60s
58+
let exp = Utc::now() + Duration::minutes(1);
59+
let claims = Claims {
60+
exp: exp.timestamp() as usize,
61+
auth: mock_user.email,
62+
};
63+
let token_str = encode(&Header::default(), &claims, &key_enc).expect("JWT encode should work");
6064

6165
cookies.add(
6266
Cookie::build(JWT_KEY, token_str)
6367
// if not set, the path defaults to the path from which it was called - prohibiting gql on root if login is on /api
6468
.path("/")
69+
.http_only(true)
6570
.finish(),
6671
);
6772

0 commit comments

Comments
 (0)
Please sign in to comment.