Skip to content

Commit

Permalink
Add signInWithNewPassword GraphQL API
Browse files Browse the repository at this point in the history
Close #14

Changed `sign-in` GraphQL API logic.
- Returns `Err` if `last_signin_time` of `account` is `None`.
  • Loading branch information
henry0715-dev committed Aug 29, 2024
1 parent 3b4862c commit 97ffb90
Show file tree
Hide file tree
Showing 2 changed files with 251 additions and 65 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
315 changes: 250 additions & 65 deletions src/graphql/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthPayload> {
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<AuthPayload> {
if password.is_empty() || username.is_empty() {
return Err("Username and password cannot be empty.".into());

Check warning on line 339 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L339

Added line #L339 was not covered by tests
}
common_sign_in_logic(ctx, &username, Some(&password), Some(&new_password), true).await
}

/// Revokes the given access token
Expand Down Expand Up @@ -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<AuthPayload> {
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());

Check warning on line 424 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L423-L424

Added lines #L423 - L424 were not covered by tests
}
}

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

Check warning on line 437 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L437

Added line #L437 was not covered by tests
}
} else {
None

Check warning on line 440 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L440

Added line #L440 was not covered by tests
}
})
.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());

Check warning on line 453 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L452-L453

Added lines #L452 - L453 were not covered by tests
}

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)?;
}

Check warning on line 463 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L463

Added line #L463 was not covered by tests
} 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());
}
}

Check warning on line 468 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L468

Added line #L468 was not covered by tests

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())

Check warning on line 489 in src/graphql/account.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql/account.rs#L488-L489

Added lines #L488 - L489 were not covered by tests
}
}

/// Returns the expiration time according to the account policy.
///
/// # Errors
Expand Down Expand Up @@ -631,7 +701,7 @@ pub fn reset_admin_password(store: &Store) -> anyhow::Result<()> {
fn initial_credential() -> anyhow::Result<types::Account> {
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,
Expand All @@ -641,6 +711,7 @@ fn initial_credential() -> anyhow::Result<types::Account> {
None,
None,
)?;
initial_account.update_last_signin_time();

Ok(initial_account)
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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"));
}
}

0 comments on commit 97ffb90

Please sign in to comment.