diff --git a/CHANGELOG.md b/CHANGELOG.md index b22a592..2d573dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). - Added a `language` field to the `Account`. Consequently, the `account` and `accountList` API responses now include this field. The `insertAccount` and `updateAccount` GraphQL API endpoints are also updated to support the field. +- Added `signInWithNewPassword` GraphQL API for signing in with a new password. ### Fixed diff --git a/src/graphql/account.rs b/src/graphql/account.rs index c60aad7..bdf9caa 100644 --- a/src/graphql/account.rs +++ b/src/graphql/account.rs @@ -287,78 +287,58 @@ impl AccountMutation { Ok(username) } - /// Authenticates with the given username and password + /// Authenticate with the given username and password. + /// If the `last_signin_time` value of the `account` is `None`, the operation will fail, and + /// it should be guided to call `signInWithNewPassword` GraphQL API. + /// + /// # Guide + /// If the `signIn` GraphQL API response is the error message "a password change is + /// required to proceed", you can call the `signInWithNewPassword` GraphQL API. + /// + /// ```ignore + /// r#"mutation { + /// signInWithNewPassword(username: "user", password: "pw", newPassword: "pw2") { + /// token + /// } + /// }"# + /// ``` + /// + /// # Returns + /// + /// Returns a `Result` containing an `AuthPayload` if the sign-in is successful. + /// + /// # Errors + /// + /// an error message (`String`) if any of the checks fail or an error occurs during the process. async fn sign_in( &self, ctx: &Context<'_>, username: String, password: String, ) -> Result { - let store = crate::graphql::get_store(ctx).await?; - let account_map = store.account_map(); - let client_ip = get_client_ip(ctx); - - if let Some(mut account) = account_map.get(&username)? { - if let Some(allow_access_from) = account.allow_access_from.as_ref() { - if let Some(socket) = client_ip { - let ip = socket.ip(); - if !allow_access_from.contains(&ip) { - info!("access denied for {username} from IP: {ip}"); - return Err("access denied from this IP".into()); - } - } else { - info!("unable to retrieve client IP for {username}"); - return Err("unable to retrieve client IP".into()); - } - } - - if account.verify_password(&password) { - if let Some(max_parallel_sessions) = account.max_parallel_sessions { - let access_token_map = store.access_token_map(); - let count = access_token_map - .iter(Direction::Forward, Some(username.as_bytes())) - .filter_map(|res| { - if let Ok(access_token) = res { - if access_token.username == username { - Some(access_token) - } else { - None - } - } else { - None - } - }) - .count(); - if count >= max_parallel_sessions as usize { - info!("maximum parallel sessions exceeded for {username}"); - return Err("maximum parallel sessions exceeded".into()); - } - } - - let (token, expiration_time) = - create_token(account.username.clone(), account.role.to_string())?; - account.update_last_signin_time(); - account_map.put(&account)?; - - insert_token(&store, &token, &username)?; + common_sign_in_logic(ctx, &username, Some(&password), None, false).await + } - if let Some(socket) = client_ip { - info!("{username} signed in from IP: {}", socket.ip()); - } else { - info!("{username} signed in"); - } - Ok(AuthPayload { - token, - expiration_time, - }) - } else { - info!("wrong password for {username}"); - Err("incorrect username or password".into()) - } - } else { - info!("{username} is not a valid username"); - Err("incorrect username or password".into()) + /// Authenticate with the given username and password, and update the new password. + /// + /// # Returns + /// + /// Returns a `Result` containing an `AuthPayload` if the sign-in is successful. + /// + /// # Errors + /// + /// an error message (`String`) if any of the checks fail or an error occurs during the process. + async fn sign_in_with_new_password( + &self, + ctx: &Context<'_>, + username: String, + password: String, + new_password: String, + ) -> Result { + if password.is_empty() || username.is_empty() { + return Err("Username and password cannot be empty.".into()); } + common_sign_in_logic(ctx, &username, Some(&password), Some(&new_password), true).await } /// Revokes the given access token @@ -420,6 +400,96 @@ impl AccountMutation { } } +async fn common_sign_in_logic( + ctx: &Context<'_>, + username: &str, + password: Option<&str>, + new_password: Option<&str>, + is_update_password: bool, +) -> Result { + let store = crate::graphql::get_store(ctx).await?; + let account_map = store.account_map(); + let client_ip = get_client_ip(ctx); + + if let Some(mut account) = account_map.get(username)? { + if let Some(allow_access_from) = account.allow_access_from.as_ref() { + if let Some(socket) = client_ip { + let ip = socket.ip(); + if !allow_access_from.contains(&ip) { + info!("access denied for {username} from IP: {ip}"); + return Err("access denied from this IP".into()); + } + } else { + info!("unable to retrieve client IP for {username}"); + return Err("unable to retrieve client IP".into()); + } + } + + if let Some(max_parallel_sessions) = account.max_parallel_sessions { + let access_token_map = store.access_token_map(); + let count = access_token_map + .iter(Direction::Forward, Some(username.as_bytes())) + .filter_map(|res| { + if let Ok(access_token) = res { + if access_token.username == *username { + Some(access_token) + } else { + None + } + } else { + None + } + }) + .count(); + if count >= max_parallel_sessions as usize { + info!("maximum parallel sessions exceeded for {username}"); + return Err("maximum parallel sessions exceeded".into()); + } + } + + if let Some(provided_password) = password { + if !account.verify_password(provided_password) { + info!("wrong password for {username}"); + return Err("incorrect username or password".into()); + } + + if is_update_password { + if let Some(new_password) = new_password { + if provided_password.eq(new_password) { + info!("password is the same as the previous one for {username}"); + return Err("password is the same as the previous one".into()); + } + account.update_password(new_password)?; + } + } else if account.last_signin_time().is_none() { + info!("a password change is required to proceed for {username}"); + return Err("a password change is required to proceed".into()); + } + } + + let (token, expiration_time) = + create_token(account.username.clone(), account.role.to_string())?; + account.update_last_signin_time(); + account_map.put(&account)?; + + insert_token(&store, &token, username)?; + + if let Some(socket) = client_ip { + info!("{username} signed in from IP: {}", socket.ip()); + } else { + info!("{username} signed in"); + } + + Ok(AuthPayload { + token, + expiration_time, + }) + } else { + info!("{username} is not a valid username"); + Err("incorrect username or password".into()) + } +} + /// Returns the expiration time according to the account policy. /// /// # Errors @@ -631,7 +701,7 @@ pub fn reset_admin_password(store: &Store) -> anyhow::Result<()> { fn initial_credential() -> anyhow::Result { let (username, password) = read_review_admin()?; - let initial_account = review_database::types::Account::new( + let mut initial_account = types::Account::new( &username, &password, database::Role::SystemAdministrator, @@ -641,6 +711,7 @@ fn initial_credential() -> anyhow::Result { None, None, )?; + initial_account.update_last_signin_time(); Ok(initial_account) } @@ -683,6 +754,14 @@ mod tests { BoxedAgentManager, MockAgentManager, RoleGuard, TestSchema, }; + async fn update_account_last_signin_time(schema: &TestSchema, name: &str) { + let store = schema.store().await; + let map = store.account_map(); + let mut account = map.get(name).unwrap().unwrap(); + account.update_last_signin_time(); + let _ = map.put(&account).is_ok(); + } + #[tokio::test] #[serial] async fn pagination() { @@ -1312,6 +1391,8 @@ mod tests { .await; assert_eq!(res.data.to_string(), r#"{insertAccount: "u1"}"#); + update_account_last_signin_time(&schema, "u1").await; + let res = schema .execute( r#"mutation { @@ -1387,6 +1468,8 @@ mod tests { .await; assert_eq!(res.data.to_string(), r#"{insertAccount: "u1"}"#); + update_account_last_signin_time(&schema, "u1").await; + let res = schema .execute( r#"mutation { @@ -1422,6 +1505,8 @@ mod tests { .await; assert_eq!(res.data.to_string(), r#"{insertAccount: "u1"}"#); + update_account_last_signin_time(&schema, "u1").await; + let res = schema .execute( r#"mutation { @@ -1434,4 +1519,104 @@ mod tests { assert!(res.is_err()); } + + #[tokio::test] + async fn password_required_proceed() { + let schema = TestSchema::new().await; + let res = schema + .execute( + r#"mutation { + insertAccount( + username: "u2", + password: "pw2", + role: "SECURITY_ADMINISTRATOR", + name: "User One", + department: "Test", + maxParallelSessions: 2 + ) + }"#, + ) + .await; + assert_eq!(res.data.to_string(), r#"{insertAccount: "u2"}"#); + + let res = schema + .execute( + r#"mutation { + signIn(username: "u2", password: "pw2") { + token + } + }"#, + ) + .await; + + assert_eq!( + res.errors.first().unwrap().message.to_string(), + "a password change is required to proceed".to_string() + ); + } + + #[tokio::test] + async fn sign_in_with_new_password_proceed() { + let schema = TestSchema::new().await; + let res = schema + .execute( + r#"mutation { + insertAccount( + username: "u3", + password: "pw3", + role: "SECURITY_ADMINISTRATOR", + name: "User One", + department: "Test", + maxParallelSessions: 2 + ) + }"#, + ) + .await; + assert_eq!(res.data.to_string(), r#"{insertAccount: "u3"}"#); + + let res = schema + .execute( + r#"mutation { + signIn(username: "u3", password: "pw3") { + token + } + }"#, + ) + .await; + + assert_eq!( + res.errors.first().unwrap().message.to_string(), + "a password change is required to proceed".to_string() + ); + + let res = schema + .execute( + r#"mutation { + signInWithNewPassword(username: "u3", password: "pw3", newPassword: "pw3") { + token + } + }"#, + ) + .await; + assert_eq!( + res.errors.first().unwrap().message.to_string(), + "password is the same as the previous one".to_string() + ); + + let res = schema + .execute( + r#"mutation { + signInWithNewPassword(username: "u3", password: "pw3", newPassword: "pw4") { + token + } + }"#, + ) + .await; + assert!(res.is_ok()); + + let store = schema.store().await; + let map = store.account_map(); + let account = map.get("u3").unwrap().unwrap(); + assert!(account.verify_password("pw4")); + } }