Skip to content

Commit 1e47722

Browse files
committed
Authenticate client.
1 parent bb1b87b commit 1e47722

File tree

19 files changed

+433
-9
lines changed

19 files changed

+433
-9
lines changed

chloria-backend/Cargo.lock

Lines changed: 66 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

chloria-backend/chloria-api/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ edition = "2021"
55

66
[dependencies]
77
anyhow = "1.0.96"
8+
argon2 = { version = "0.5.3", features = ["std"] }
9+
async-trait = "0.1.86"
810
axum = "0.8.1"
911
axum-extra = { version = "0.10.0", features = ["typed-header"] }
10-
diesel = { version = "2.2.7", features = ["postgres"] }
12+
diesel = { version = "2.2.7", features = ["postgres", "r2d2"] }
1113
jsonwebtoken = "9.3.1"
14+
mockall = "0.13.1"
1215
serde = "1.0.218"
1316
serde_json = "1.0.140"
1417
serde_with = "3.12.0"
18+
strum = { version = "0.27.1", features = ["derive"] }
1519
tokio = { version = "1.43.0", features = ["rt-multi-thread"] }
1620
tower = "0.5.2"

chloria-backend/chloria-api/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## Manual guide
2+
### Add a static client credential
3+
Generate a hashed secret using Argon2:
4+
```bash
5+
apt update -y
6+
apt install -y argon2
7+
echo -n "${SECRET}" | argon2 ${SALT} -id
8+
```
9+
> Type: Argon2id\
10+
> ...\
11+
> Hash: ...\
12+
> Encoded: ${HASHED_SECRET}\
13+
> ...
14+
- `${SECRET}`: Your private secret
15+
- `${SALT}`: A random salt value
16+
- `${HASHED_SECRET}`: Has the format `$argon2id...`
17+
18+
Insert a new credential into the database:
19+
```sql
20+
INSERT INTO clients(authentication_method, authentication_registry) VALUES ('static', '${AUTHENTICATION_REGISTRY}');
21+
22+
-- Suppose the id of the new client is `1` but it can be any value
23+
INSERT INTO client_credentials(id, api_key, api_secret) VALUES (1, '${API_KEY}', '${HASHED_SECRET}');
24+
```
25+
- `${AUTHENTICATION_REGISTRY}`: A unique value to distinguish it from other static clients (e.g., `power-user`, `first-subscriber`,...)
26+
- `${API_KEY}`: Randomly generated and unique
27+
- `${HASHED_SECRET}`: The hashed secret generated in the previous step
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::Result;
4+
use async_trait::async_trait;
5+
use strum::Display;
6+
7+
use super::{
8+
super::{
9+
ports::{hashing_algorithm::HashingAlgorithm, repository::Repository},
10+
workshop::Workshop,
11+
},
12+
Case,
13+
};
14+
15+
pub(crate) struct AuthenticateCaseInput {
16+
pub(crate) api_key: String,
17+
pub(crate) api_secret: String,
18+
}
19+
20+
#[derive(Display)]
21+
pub(crate) enum AuthenticateCaseOutput {
22+
NotFound,
23+
IncorrectSecret,
24+
Success,
25+
}
26+
27+
struct AuthenticateCase {
28+
repository: Arc<dyn Repository>,
29+
hashing_algorithm: Box<dyn HashingAlgorithm>,
30+
input: AuthenticateCaseInput,
31+
}
32+
33+
impl Workshop {
34+
pub(crate) async fn execute_authenticate_case(
35+
&self,
36+
input: AuthenticateCaseInput,
37+
) -> Result<AuthenticateCaseOutput> {
38+
let case = AuthenticateCase {
39+
repository: Arc::clone(&self.repository),
40+
hashing_algorithm: self.hashing_algorithm.clone(),
41+
input,
42+
};
43+
self.run_case(case).await
44+
}
45+
}
46+
47+
#[async_trait]
48+
impl Case for AuthenticateCase {
49+
type Output = AuthenticateCaseOutput;
50+
51+
async fn execute(self) -> Result<Self::Output> {
52+
let Some(api_secret_value) = self.repository.select_client_api_secret(&self.input.api_key).await? else {
53+
return Ok(AuthenticateCaseOutput::NotFound);
54+
};
55+
if !self
56+
.hashing_algorithm
57+
.verify(&self.input.api_secret, &api_secret_value)?
58+
{
59+
return Ok(AuthenticateCaseOutput::IncorrectSecret);
60+
}
61+
Ok(AuthenticateCaseOutput::Success)
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use std::sync::Arc;
68+
69+
use anyhow::Result;
70+
71+
use super::{
72+
super::{
73+
super::ports::{hashing_algorithm::MockHashingAlgorithm, repository::MockRepository},
74+
Case,
75+
},
76+
AuthenticateCase, AuthenticateCaseInput, AuthenticateCaseOutput,
77+
};
78+
79+
#[tokio::test]
80+
async fn confirm_successful_authentication() -> Result<()> {
81+
let mut mock_repository = MockRepository::new();
82+
mock_repository
83+
.expect_select_client_api_secret()
84+
.times(1)
85+
.returning(|_| Ok(Some("".to_string())));
86+
let mut mock_hashing_algorithm = MockHashingAlgorithm::new();
87+
mock_hashing_algorithm
88+
.expect_verify()
89+
.times(1)
90+
.returning(|_, _| Ok(true));
91+
let case = AuthenticateCase {
92+
repository: Arc::new(mock_repository),
93+
hashing_algorithm: Box::new(mock_hashing_algorithm),
94+
input: AuthenticateCaseInput {
95+
api_key: "".to_string(),
96+
api_secret: "".to_string(),
97+
},
98+
};
99+
let output = case.execute().await?;
100+
assert!(matches!(output, AuthenticateCaseOutput::Success));
101+
Ok(())
102+
}
103+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
pub(crate) mod authenticate;
2+
3+
use anyhow::Result;
4+
use async_trait::async_trait;
5+
6+
#[async_trait]
7+
pub(super) trait Case: Send + Sync + 'static {
8+
type Output: Send;
9+
10+
async fn execute(self) -> Result<Self::Output>;
11+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub(crate) mod cases;
2+
pub(crate) mod ports;
3+
pub(crate) mod workshop;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use anyhow::Result;
2+
use mockall::mock;
3+
4+
use private::{HashingAlgorithmBoxClone, Internal};
5+
6+
pub(crate) trait HashingAlgorithm: HashingAlgorithmBoxClone + Send + Sync + 'static {
7+
fn verify(&self, secret: &str, hashed_secret: &str) -> Result<bool>;
8+
}
9+
10+
mod private {
11+
use super::HashingAlgorithm;
12+
13+
pub(crate) struct Internal;
14+
15+
pub(crate) trait HashingAlgorithmBoxClone {
16+
// Sealed with an unused internal argument to prevent this method from being called directly outside
17+
fn box_clone(&self, _internal: Internal) -> Box<dyn HashingAlgorithm>;
18+
}
19+
20+
impl<A> HashingAlgorithmBoxClone for A
21+
where
22+
A: HashingAlgorithm + Clone,
23+
{
24+
fn box_clone(&self, _internal: Internal) -> Box<dyn HashingAlgorithm> {
25+
Box::new(self.clone())
26+
}
27+
}
28+
}
29+
30+
impl Clone for Box<dyn HashingAlgorithm> {
31+
fn clone(&self) -> Self {
32+
self.box_clone(Internal)
33+
}
34+
}
35+
36+
mock! {
37+
pub(in super::super) HashingAlgorithm {}
38+
39+
impl HashingAlgorithm for HashingAlgorithm {
40+
fn verify(&self, secret: &str, hashed_secret: &str) -> Result<bool>;
41+
}
42+
43+
impl HashingAlgorithmBoxClone for HashingAlgorithm {
44+
fn box_clone(&self, _internal: Internal) -> Box<dyn HashingAlgorithm>;
45+
}
46+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub(crate) mod hashing_algorithm;
2+
pub(crate) mod repository;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use anyhow::Result;
2+
use async_trait::async_trait;
3+
use mockall::automock;
4+
5+
#[automock]
6+
#[async_trait]
7+
pub(crate) trait Repository: Send + Sync {
8+
async fn select_client_api_secret(&self, api_key_input: &str) -> Result<Option<String>>;
9+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::Result;
4+
use tokio::sync::Semaphore;
5+
6+
use super::{
7+
cases::Case,
8+
ports::{hashing_algorithm::HashingAlgorithm, repository::Repository},
9+
};
10+
11+
pub(crate) struct Config {
12+
pub(crate) case_permits_num: usize,
13+
}
14+
15+
#[derive(Clone)]
16+
pub(crate) struct Workshop {
17+
pub(super) repository: Arc<dyn Repository>,
18+
// NOTE: We could use `Arc`, but the hashing algorithm doesn't need to be shared across threads since it should have no state.
19+
// We use `Box` for simple cloning and to avoid overhead.
20+
pub(super) hashing_algorithm: Box<dyn HashingAlgorithm>,
21+
semaphore: Arc<Semaphore>,
22+
}
23+
24+
impl Workshop {
25+
pub(crate) fn new(
26+
repository: Arc<dyn Repository>,
27+
hashing_algorithm: Box<dyn HashingAlgorithm>,
28+
config: Config,
29+
) -> Self {
30+
let semaphore = Arc::new(Semaphore::new(config.case_permits_num));
31+
Self {
32+
repository,
33+
hashing_algorithm,
34+
semaphore,
35+
}
36+
}
37+
38+
pub(super) async fn run_case<C: Case>(&self, case: C) -> Result<C::Output> {
39+
let semaphore = Arc::clone(&self.semaphore);
40+
tokio::task::spawn(async move {
41+
let _permit = semaphore.acquire().await?;
42+
case.execute().await
43+
})
44+
.await?
45+
}
46+
}

0 commit comments

Comments
 (0)