Merge pull request #17 from refresh_token_auth

This commit is contained in:
Sebastian Pravda 2022-10-29 12:50:30 +02:00
commit 98e506e5ff
No known key found for this signature in database
GPG key ID: F3BC84F08EFA3F57
20 changed files with 253 additions and 235 deletions

View file

@ -1,33 +0,0 @@
use rocket::http::Status;
use rocket::outcome::Outcome;
use rocket::request::{FromRequest, Request};
use portfolio_core::token::candidate_token::CandidateToken;
use portfolio_core::token::decode_candidate_token;
pub struct TokenRequest(CandidateToken);
impl TokenRequest {
pub fn to_token(self) -> CandidateToken {
self.0
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for TokenRequest {
type Error = Status;
async fn from_request(req: &'r Request<'_>) -> Outcome<TokenRequest, (Status, Status), ()> {
if let Some(auth) = req.headers().get_one("Authorization") {
let auth_string = auth.to_string();
if auth_string.starts_with("Bearer") {
let token = auth_string[6..auth_string.len()].trim();
let token_data = decode_candidate_token(token.to_string());
if token_data.is_ok() {
return Outcome::Success(TokenRequest(token_data.ok().unwrap().claims));
}
}
}
return Outcome::Failure((Status::Unauthorized, Status::Unauthorized));
}
}

View file

@ -0,0 +1,27 @@
use portfolio_core::sea_orm::prelude::Uuid;
use rocket::http::Status;
use rocket::outcome::Outcome;
use rocket::request::{FromRequest, Request};
pub struct UUIDCookie(Uuid);
impl UUIDCookie {
pub fn value(self) -> Uuid {
self.0
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for UUIDCookie {
type Error = Status;
async fn from_request(req: &'r Request<'_>) -> Outcome<UUIDCookie, (Status, Status), ()> {
let session_id = req.cookies().get("id").unwrap().name_value().1;
println!("session_id: {}", session_id);
match Uuid::parse_str(&session_id) {
Ok(uuid) => Outcome::Success(UUIDCookie(uuid)),
Err(_) => return Outcome::Failure((Status::BadRequest, Status::BadRequest)),
}
}
}

View file

@ -1 +1 @@
pub mod candidate_jwt;
pub mod candidate_refresh_token;

View file

@ -1,7 +1,6 @@
#[macro_use]
extern crate rocket;
use guard::candidate_jwt::TokenRequest;
use portfolio_core::error::ServiceError;
use portfolio_core::services::candidate_service::CandidateService;
use requests::LoginRequest;
@ -27,6 +26,8 @@ pub use entity::candidate::Entity as Candidate;
use portfolio_core::crypto::random_8_char_string;
use crate::guard::candidate_refresh_token::UUIDCookie;
fn custom_err_from_service_err(service_err: ServiceError) -> Custom<String> {
Custom(Status::from_code(service_err.0.code).unwrap_or_default(), service_err.1.to_string())
}
@ -45,38 +46,39 @@ async fn create(conn: Connection<'_, Db>, post_form: Json<candidate::Model>) ->
Ok(plain_text_password)
}
#[get("/whoami")]
async fn validate(conn: Connection<'_, Db>, uuid_cookie: Result<UUIDCookie, Status>) -> Result<String, Custom<String>> {
let db = conn.into_inner();
let user = CandidateService::auth_user_session(db, uuid_cookie.ok().unwrap().value()).await;
match user {
Ok(user) => Ok(user.application.to_string()),
Err(err) => Err(custom_err_from_service_err(err))
}
}
#[post("/login", data = "<login_form>")]
async fn login(conn: Connection<'_, Db>, login_form: Json<LoginRequest>) -> Result<String, Custom<String>> {
let db = conn.into_inner();
println!("{} {}", login_form.application_id, login_form.password);
let jwt = CandidateService::login(db,
login_form.application_id,
login_form.password.to_owned()).await;
let session_token = CandidateService::new_session(db,
login_form.application_id,
login_form.password.to_string()
).await;
if jwt.is_ok() {
if session_token.is_ok() {
return Ok(
jwt.ok().unwrap()
session_token.ok().unwrap()
);
} else {
return Err(
custom_err_from_service_err(jwt.err().unwrap())
custom_err_from_service_err(session_token.err().unwrap())
)
}
}
#[get("/whoami")]
async fn whoami(conn: Connection<'_, Db>, token_req: Result<TokenRequest, Status>) -> Result<String, Custom<String>> {
let db = conn.into_inner();
let token = token_req.ok().unwrap().to_token();
let user = CandidateService::authenticate_candidate(db, token).await;
match user {
Ok(user) => Ok(format!("{} {}", user.name.unwrap(), user.surname.unwrap())),
Err(e) => Err(custom_err_from_service_err(e)),
}
}
#[get("/hello")]
async fn hello() -> &'static str {
"Hello, world!"
@ -94,7 +96,7 @@ async fn start() -> Result<(), rocket::Error> {
.attach(Db::init())
.attach(AdHoc::try_on_ignite("Migrations", run_migrations))
//.mount("/", FileServer::from(relative!("/static")))
.mount("/", routes![create, login, hello, whoami])
.mount("/", routes![create, login, hello, validate])
.register("/", catchers![])
.launch()
.await

View file

@ -461,4 +461,4 @@ mod tests {
PASSWORD
);
}
}
}

View file

@ -4,6 +4,9 @@ pub struct Status {
pub const INVALID_CREDENTIALS_ERROR: ServiceError = ServiceError(Status { code: 401 },
"Invalid credentials");
pub const EXPIRED_SESSION_ERROR: ServiceError = ServiceError(Status { code: 401 },
"Session expired, please login again");
pub const JWT_ERROR: ServiceError = ServiceError(Status { code: 500 },
"Error while encoding JWT");
@ -13,6 +16,9 @@ pub const USER_NOT_FOUND_ERROR: ServiceError = ServiceError(Status { code: 404 }
pub const DB_ERROR: ServiceError = ServiceError(Status { code: 500 },
"Database error");
pub const USER_NOT_FOUND_BY_JWT_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow
pub const USER_NOT_FOUND_BY_JWT_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow deleted
"User not found, please contact technical support"); // Shouldn't ever happen
pub const USER_NOT_FOUND_BY_SESSION_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow deleted
"User not found, please contact technical support"); // Shouldn't ever happen
pub struct ServiceError<'a>(pub Status, pub &'a str);

View file

@ -1,7 +1,6 @@
mod mutation;
mod query;
pub mod crypto;
pub mod token;
pub mod filetype;
pub mod services;
pub mod error;

View file

@ -1,5 +1,6 @@
use ::entity::{candidate};
use sea_orm::*;
use chrono::{Utc, Duration};
use ::entity::{candidate, session};
use sea_orm::{*, prelude::Uuid};
use crate::crypto::hash_password;
pub struct Mutation;
@ -24,4 +25,33 @@ impl Mutation {
.insert(db)
.await
}
pub async fn insert_session(
db: &DbConn,
user_id: i32,
random_uuid: Uuid,
) -> Result<session::Model, DbErr> {
session::ActiveModel {
id: Set(random_uuid),
user_id: Set(user_id),
ip_address: Set("127.0.0.1".to_string()),
created_at: Set(Utc::now().naive_local()),
expires_at: Set(Utc::now().naive_local().checked_add_signed(Duration::days(1)).unwrap()),
}
.insert(db)
.await
}
pub async fn delete_session(
db: &DbConn,
session_id: Uuid
) -> Result<DeleteResult, DbErr> {
session::ActiveModel {
id: Set(session_id),
..Default::default()
}
.delete(db)
.await
}
}

View file

@ -1,5 +1,7 @@
use ::entity::{candidate, candidate::Entity as Candidate};
use ::entity::{session, session::Entity as Session};
use sea_orm::*;
use sea_orm::prelude::Uuid;
pub struct Query;
@ -7,6 +9,18 @@ impl Query {
pub async fn find_candidate_by_id(db: &DbConn, id: i32) -> Result<Option<candidate::Model>, DbErr> {
Candidate::find_by_id(id).one(db).await
}
pub async fn find_session_by_uuid(db: &DbConn, uuid: Uuid) -> Result<Option<session::Model>, DbErr> {
Session::find_by_id(uuid).one(db).await
}
// find session by user id
pub async fn find_session_by_user_id(db: &DbConn, user_id: i32) -> Result<Option<session::Model>, DbErr> {
Session::find()
.filter(session::Column::UserId.eq(user_id))
.one(db)
.await
}
}
#[cfg(test)]

View file

@ -1,36 +1,63 @@
use chrono::Duration;
use entity::candidate;
use sea_orm::DatabaseConnection;
use sea_orm::{DatabaseConnection, prelude::Uuid, ModelTrait};
use crate::{crypto, Query, token::{generate_candidate_token, candidate_token::CandidateToken}, error::{ServiceError, USER_NOT_FOUND_ERROR, INVALID_CREDENTIALS_ERROR, DB_ERROR, USER_NOT_FOUND_BY_JWT_ID}};
use crate::{crypto::{self}, Query, error::{ServiceError, USER_NOT_FOUND_ERROR, INVALID_CREDENTIALS_ERROR, DB_ERROR, USER_NOT_FOUND_BY_JWT_ID, USER_NOT_FOUND_BY_SESSION_ID}, Mutation};
pub struct CandidateService;
impl CandidateService {
pub async fn login(db: &DatabaseConnection, id: i32, password: String) -> Result<String, ServiceError> {
let candidate = match Query::find_candidate_by_id(db, id).await {
pub async fn new_session(db: &DatabaseConnection, user_id: i32, password: String) -> Result<String, ServiceError> {
let candidate = match Query::find_candidate_by_id(db, user_id).await {
Ok(candidate) => match candidate {
Some(candidate) => candidate,
None => return Err(USER_NOT_FOUND_ERROR)
},
Err(_) => {return Err(DB_ERROR)}
};
let valid = crypto::verify_password(password,candidate.code.clone()).await
.expect("Invalid password");
if !valid {
return Err(INVALID_CREDENTIALS_ERROR)
// compare passwords
match crypto::verify_password(password,candidate.code.clone()).await {
Ok(valid) => {
if !valid {
return Err(INVALID_CREDENTIALS_ERROR)
}
},
Err(_) => {return Err(INVALID_CREDENTIALS_ERROR)}
}
let jwt = generate_candidate_token(candidate); // TODO better error handling
Ok(jwt)
// TODO delete old sessions?
// user is authenticated, generate a session
let random_uuid: Uuid = Uuid::new_v4();
let session = match Mutation::insert_session(db, user_id, random_uuid).await {
Ok(session) => session,
Err(_) => return Err(DB_ERROR)
};
Ok(session.id.to_string())
}
pub async fn authenticate_candidate(db: &DatabaseConnection, token: CandidateToken) -> Result<candidate::Model, ServiceError> {
let candidate = match Query::find_candidate_by_id(db, token.application_id).await {
pub async fn auth_user_session(db: &DatabaseConnection, uuid: Uuid) -> Result<candidate::Model, ServiceError> {
let session = match Query::find_session_by_uuid(db, uuid).await {
Ok(session) => match session {
Some(session) => session,
None => return Err(USER_NOT_FOUND_BY_SESSION_ID)
},
Err(_) => {return Err(DB_ERROR)}
};
let limit = session.created_at.checked_add_signed(Duration::days(1)).unwrap();
let now = chrono::Utc::now().naive_utc();
// check if session is expired
if now > limit {
// delete session
Mutation::delete_session(db, session.id).await.unwrap();
return Err(USER_NOT_FOUND_BY_SESSION_ID)
}
let candidate = match session.find_related(candidate::Entity).one(db).await {
Ok(candidate) => match candidate {
Some(candidate) => candidate,
None => return Err(USER_NOT_FOUND_BY_JWT_ID)
@ -39,7 +66,7 @@ impl CandidateService {
};
Ok(candidate)
}
}
}
@ -47,19 +74,23 @@ impl CandidateService {
#[cfg(test)]
mod tests {
use entity::candidate;
use sea_orm::{DbConn, Database, sea_query::TableCreateStatement, DbBackend, Schema, ConnectionTrait};
use sea_orm::{DbConn, Database, sea_query::TableCreateStatement, DbBackend, Schema, ConnectionTrait, prelude::Uuid};
use serde_json::json;
use crate::{crypto, Mutation, services::candidate_service::CandidateService, token};
use crate::{crypto, Mutation, services::candidate_service::CandidateService};
#[cfg(test)]
async fn get_memory_sqlite_connection() -> DbConn {
use entity::session;
let base_url = "sqlite::memory:";
let db: DbConn = Database::connect(base_url).await.unwrap();
let schema = Schema::new(DbBackend::Sqlite);
let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity);
let stmt2: TableCreateStatement = schema.create_table_from_entity(session::Entity);
db.execute(db.get_database_backend().build(&stmt)).await.unwrap();
db.execute(db.get_database_backend().build(&stmt2)).await.unwrap();
db
}
@ -79,22 +110,46 @@ mod tests {
assert_ne!(candidate.code, SECRET.to_string());
assert!(crypto::verify_password(SECRET.to_string(), candidate.code).await.ok().unwrap());
}
#[tokio::test]
async fn test_candidate_jwt() {
async fn test_candidate_session_correct_password() {
let db = &get_memory_sqlite_connection().await;
let form = serde_json::from_value(json!({
"application": 5555555,
})).unwrap();
let candidate = Mutation::create_candidate(&db, form, &"Tajny_kod".to_string()).await.unwrap();
let jwt = CandidateService::login(db, 5555555, "Tajny_kod".to_string()).await.ok().unwrap();
let claims = token::decode_candidate_token(jwt).ok().unwrap().claims;
assert_eq!(claims.application_id, candidate.application);
Mutation::create_candidate(&db, form, &"Tajny_kod".to_string()).await.unwrap();
// correct password
let session = CandidateService::new_session(
db,
5555555,
"Tajny_kod".to_string()
)
.await.ok().unwrap();
// println!("{}", session.err().unwrap().1);
assert!(
CandidateService::auth_user_session(db, Uuid::parse_str(&session).unwrap())
.await
.is_ok()
);
}
#[tokio::test]
async fn test_candidate_session_incorrect_password() {
let db = &get_memory_sqlite_connection().await;
let form = serde_json::from_value(json!({
"application": 5555555,
})).unwrap();
let candidate_form = Mutation::create_candidate(&db, form, &"Tajny_kod".to_string()).await.unwrap();
// incorrect password
assert!(
CandidateService::new_session(db, candidate_form.application, "Spatny_kod".to_string()).await.is_err()
);
}
}

View file

@ -1,9 +0,0 @@
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct AdminToken {
// issued at
pub iat: i64,
// expiration
pub exp: i64,
}

View file

@ -1,26 +0,0 @@
use chrono::Utc;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct CandidateToken {
// issued at
pub iat: i64,
// expiration
pub exp: i64,
pub application_id: i32,
pub name: String,
pub surname: String,
}
impl CandidateToken {
pub fn generate(application_id: i32, name: String, surname: String) -> Self {
let now = Utc::now().timestamp();
CandidateToken {
iat: now,
exp: now + 60 * 60, // 1 hour for now
application_id,
name,
surname,
}
}
}

View file

@ -1,98 +0,0 @@
pub mod admin_token;
pub mod candidate_token;
use chrono::Utc;
use entity::{admin, candidate};
use jsonwebtoken::errors::Result;
use jsonwebtoken::TokenData;
use jsonwebtoken::{DecodingKey, EncodingKey};
use jsonwebtoken::{Header, Validation};
use admin_token::AdminToken;
use candidate_token::CandidateToken;
use serde::Deserialize;
const ONE_WEEK: i64 = 60 * 60 * 24 * 7;
pub fn generate_candidate_token(candidate: candidate::Model) -> String {
let now = Utc::now().timestamp();
let payload = CandidateToken {
iat: now,
exp: now + ONE_WEEK,
application_id: candidate.application,
name: candidate.name.unwrap_or_else(|| "".into()),
surname: candidate.surname.unwrap_or_else(|| "".into()),
};
jsonwebtoken::encode(
&Header::default(),
&payload,
&EncodingKey::from_secret(include_bytes!("secret.key")),
)
.unwrap()
}
pub fn generate_admin_token(_admin: admin::Model) -> String {
let now = Utc::now().timestamp();
let payload = AdminToken {
iat: now,
exp: now + ONE_WEEK,
};
jsonwebtoken::encode(
&Header::default(),
&payload,
&EncodingKey::from_secret(include_bytes!("secret.key")),
)
.unwrap()
}
pub fn decode_token<T: for<'a> Deserialize<'a>>(token: String) -> Result<TokenData<T>> {
jsonwebtoken::decode::<T>(
&token,
&DecodingKey::from_secret(include_bytes!("secret.key")),
&Validation::default(),
)
}
pub fn decode_candidate_token(token: String) -> Result<TokenData<CandidateToken>> {
decode_token(token)
}
pub fn decode_admin_token(token: String) -> Result<TokenData<AdminToken>> {
decode_token(token)
}
#[test]
fn test_encode_decode_token() {
let candidate_model = candidate::Model {
application: 101204,
code: "random_code".to_string(),
birth_surname: None,
birthplace: None,
birthdate: None,
address: None,
telephone: None,
citizenship: None,
sex: None,
study: None,
personal_identification_number: None,
personal_identification_number_hash: None,
public_key: "None".to_owned(),
private_key: "None".to_owned(),
created_at: Utc::now().naive_local(),
updated_at: Utc::now().naive_local(),
name: Some("Uplnej".to_string()),
surname: Some("Magor".to_string()),
email: Some("email.uchazece@centrum.cz".to_string()),
};
let jwt = generate_candidate_token(candidate_model.clone());
let decoded = decode_candidate_token(jwt).unwrap();
let token_claims = decoded.claims;
assert_eq!(candidate_model.name.unwrap(), token_claims.name);
assert_eq!(candidate_model.surname.unwrap(), token_claims.surname);
}

View file

@ -1 +0,0 @@
temp

View file

@ -37,6 +37,15 @@ pub struct Model {
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
pub enum Relation {
#[sea_orm(has_many = "super::session::Entity")]
Session,
}
impl Related<super::session::Entity> for Entity {
fn to() -> RelationDef {
Relation::Session.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -7,13 +7,28 @@ use sea_orm::entity::prelude::*;
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub hashed_token: String,
pub user_id: i32,
pub ip_address: String,
pub created_at: DateTime,
pub updated_at: DateTime,
pub expires_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
pub enum Relation {
#[sea_orm(
belongs_to = "super::candidate::Entity",
from = "Column::UserId",
to = "super::candidate::Column::Application",
on_update = "Cascade",
on_delete = "Cascade"
)]
Candidate,
}
impl Related<super::candidate::Entity> for Entity {
fn to() -> RelationDef {
Relation::Candidate.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -5,6 +5,7 @@ mod m20221024_121621_create_candidate;
mod m20221024_124701_create_parent;
mod m20221024_134454_fill_admin;
mod m20221025_154422_create_session;
mod m20221027_194728_session_create_user_fk;
pub struct Migrator;
@ -17,6 +18,7 @@ impl MigratorTrait for Migrator {
Box::new(m20221024_124701_create_parent::Migration),
Box::new(m20221024_134454_fill_admin::Migration::default()),
Box::new(m20221025_154422_create_session::Migration),
Box::new(m20221027_194728_session_create_user_fk::Migration),
]
}
}

View file

@ -50,7 +50,7 @@ impl MigrationTrait for Migration {
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Candidate {
pub enum Candidate {
Table,
Application,
Code,

View file

@ -18,10 +18,10 @@ impl MigrationTrait for Migration {
.unique_key()
.primary_key(),
)
.col(ColumnDef::new(Session::HashedToken).string().not_null())
.col(ColumnDef::new(Session::UserId).integer().not_null())
.col(ColumnDef::new(Session::IpAddress).string().not_null())
.col(ColumnDef::new(Session::CreatedAt).date_time().not_null())
.col(ColumnDef::new(Session::UpdatedAt).date_time().not_null())
.col(ColumnDef::new(Session::ExpiresAt).date_time().not_null())
.to_owned(),
)
.await
@ -36,11 +36,11 @@ impl MigrationTrait for Migration {
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Session {
pub enum Session {
Table,
Id,
HashedToken,
UserId,
IpAddress,
CreatedAt,
UpdatedAt
ExpiresAt,
}

View file

@ -0,0 +1,26 @@
use sea_orm_migration::prelude::*;
use crate::{m20221025_154422_create_session::Session, m20221024_121621_create_candidate::Candidate};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.create_foreign_key(ForeignKey::create()
.name("user_fk")
.from(Session::Table, Session::UserId)
.to(Candidate::Table, Candidate::Application)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade)
.to_owned()).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_foreign_key(ForeignKey::drop()
.name("user_fk")
.table(Session::Table)
.to_owned()).await
}
}