Skip to content

Commit 8c768f1

Browse files
committed
Add auth passthough (auth_query)
Adds a feature that allows setting auth passthrough for md5 auth. It adds 3 new (general and pool) config parameters: - `auth_query`: An string containing a query that will be executed on boot to obtain the hash of a given user. This query have to use a placeholder `$1`, so pgcat can replace it with the user its trying to fetch the hash from. - `auth_query_user`: The user to use for connecting to the server and executing the auth_query. - `auth_query_password`: The password to use for connecting to the server and executing the auth_query. The configuration can be done either on the general config (so pools share them) or in a per-pool basis. The behavior is, at boot time, when validating server connections, a hash is fetched per server and stored in the pool. When new server connections are created, and no cleartext password is specified, the obtained hash is used for creating them, if the hash could not be obtained for whatever reason, it retries it. When client authentication is tried, it uses cleartext passwords if specified, it not, it checks whether we have query_auth set up, if so, it tries to use the obtained hash for making client auth. If there is no hash (we could not obtain one when validating the connection), a new fetch is tried. Once we have a hash, we authenticate using it against whathever the client has sent us, if there is a failure we refetch the hash and retry auth (so password changes can be done). The idea with this 'retrial' mechanism is to make it fault tolerant, so if for whatever reason hash could not be obtained during connection validation, or the password has change, we can still connect later.
1 parent c687e23 commit 8c768f1

19 files changed

+874
-44
lines changed

.rustfmt.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
edition = "2021"
2+
hard_tabs = false

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ version = "0.6.0-alpha1"
44
edition = "2021"
55

66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7-
87
[dependencies]
98
tokio = { version = "1", features = ["full"] }
109
bytes = "1"
@@ -37,6 +36,8 @@ exitcode = "1.1.2"
3736
futures = "0.3"
3837
socket2 = { version = "0.4.7", features = ["all"] }
3938
nix = "0.26.2"
39+
postgres-protocol = "0.6.4"
40+
fallible-iterator = "0.2"
4041

4142
[target.'cfg(not(target_env = "msvc"))'.dependencies]
4243
jemallocator = "0.5.0"

dev/docker-compose.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ services:
3434
POSTGRES_INITDB_ARGS: --auth-local=md5 --auth-host=md5 --auth=md5
3535
PGPORT: 5432
3636
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "pg_stat_statements.track=all", "-p", "5432"]
37-
3837
pg2:
3938
<<: *common-definition-pg
4039
environment:
@@ -56,6 +55,13 @@ services:
5655
POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 --auth=scram-sha-256
5756
PGPORT: 9432
5857
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "pg_stat_statements.track=all", "-p", "9432"]
58+
pg5:
59+
<<: *common-definition-pg
60+
environment:
61+
<<: *common-env-pg
62+
POSTGRES_INITDB_ARGS: --auth-local=md5 --auth-host=md5 --auth=md5
63+
PGPORT: 10432
64+
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "pg_stat_statements.track=all", "-p", "10432"]
5965

6066
toxiproxy:
6167
build: .
@@ -69,6 +75,7 @@ services:
6975
- pg2
7076
- pg3
7177
- pg4
78+
- pg5
7279

7380
pgcat-shell:
7481
stdin_open: true

src/auth_passthrough.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
use crate::errors::Error;
2+
use crate::server::Server;
3+
use log::debug;
4+
5+
#[derive(Clone, Debug)]
6+
pub struct AuthPassthrough {
7+
password: String,
8+
query: String,
9+
user: String,
10+
}
11+
12+
impl AuthPassthrough {
13+
/// Initializes an AuthPassthrough.
14+
pub fn new(query: &str, user: &str, password: &str) -> Self {
15+
AuthPassthrough {
16+
password: password.to_string(),
17+
query: query.to_string(),
18+
user: user.to_string(),
19+
}
20+
}
21+
22+
/// Returns an AuthPassthrough given the pool configuration.
23+
/// If any of required values is not set, None is returned.
24+
pub fn from_pool_config(pool_config: &crate::config::Pool) -> Option<Self> {
25+
if pool_config.is_auth_query_configured() {
26+
return Some(AuthPassthrough::new(
27+
pool_config.auth_query.as_ref().unwrap(),
28+
pool_config.auth_query_user.as_ref().unwrap(),
29+
pool_config.auth_query_password.as_ref().unwrap(),
30+
));
31+
}
32+
33+
None
34+
}
35+
36+
/// Returns an AuthPassthrough given the pool settings.
37+
/// If any of required values is not set, None is returned.
38+
pub fn from_pool_settings(pool_settings: &crate::pool::PoolSettings) -> Option<Self> {
39+
let pool_config = crate::config::Pool {
40+
auth_query: pool_settings.auth_query.clone(),
41+
auth_query_password: pool_settings.auth_query_password.clone(),
42+
auth_query_user: pool_settings.auth_query_user.clone(),
43+
..Default::default()
44+
};
45+
46+
AuthPassthrough::from_pool_config(&pool_config)
47+
}
48+
49+
/// Connects to server and executes auth_query for the specified address.
50+
/// If the response is a row with two columns containing the username set in the address.
51+
/// and its MD5 hash, the MD5 hash returned.
52+
///
53+
/// Note that the query is executed, changing $1 with the name of the user
54+
/// this is so we only hold in memory (and transfer) the least amount of 'sensitive' data.
55+
/// Also, it is compatible with pgbouncer.
56+
///
57+
/// # Arguments
58+
///
59+
/// * `address` - An Address of the server we want to connect to. The username for the hash will be obtained from this value.
60+
///
61+
/// # Examples
62+
///
63+
/// ```
64+
/// use pgcat::auth_passthrough::AuthPassthrough;
65+
/// use pgcat::config::Address;
66+
/// let auth_passthrough = AuthPassthrough::new("SELECT * FROM public.user_lookup('$1');", "postgres", "postgres");
67+
/// auth_passthrough.fetch_hash(&Address::default());
68+
/// ```
69+
///
70+
pub async fn fetch_hash(&self, address: &crate::config::Address) -> Result<String, Error> {
71+
let auth_user = crate::config::User {
72+
username: self.user.clone(),
73+
password: Some(self.password.clone()),
74+
pool_size: 1,
75+
statement_timeout: 0,
76+
};
77+
78+
let user = &address.username;
79+
80+
debug!("Connecting to server to obtain auth hashes.");
81+
let auth_query = self.query.replace("$1", user);
82+
match Server::exec_simple_query(address, &auth_user, &auth_query).await {
83+
Ok(password_data) => {
84+
if password_data.len() == 2 && password_data.first().unwrap() == user {
85+
if let Some(stripped_hash) = password_data.last().unwrap().to_string().strip_prefix("md5") {
86+
Ok(stripped_hash.to_string())
87+
}
88+
else {
89+
Err(Error::AuthPassthroughError(
90+
"Obtained hash from auth_query does not seem to be in md5 format.".to_string(),
91+
))
92+
}
93+
} else {
94+
Err(Error::AuthPassthroughError(
95+
"Data obtained from query does not follow the scheme 'user','hash'."
96+
.to_string(),
97+
))
98+
}
99+
}
100+
Err(err) => {
101+
Err(Error::AuthPassthroughError(
102+
format!("Error trying to obtain password from auth_query, ignoring hash for user '{}'. Error: {:?}",
103+
user, err)))
104+
}
105+
}
106+
}
107+
}

src/client.rs

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ use tokio::sync::mpsc::Sender;
1010

1111
use crate::admin::{generate_server_info_for_admin, handle_admin};
1212
use crate::config::{get_config, Address, PoolMode};
13-
use crate::constants::*;
1413
use crate::errors::Error;
1514
use crate::messages::*;
1615
use crate::pool::{get_pool, ClientServerMap, ConnectionPool};
1716
use crate::query_router::{Command, QueryRouter};
1817
use crate::server::Server;
1918
use crate::stats::{get_reporter, Reporter};
2019
use crate::tls::Tls;
20+
use crate::{auth_passthrough::AuthPassthrough, constants::*};
2121

2222
use tokio_rustls::server::TlsStream;
2323

@@ -359,6 +359,20 @@ pub async fn startup_tls(
359359
}
360360
}
361361

362+
async fn refetch_auth_hash(pool: &ConnectionPool) -> Result<String, Error> {
363+
let address = pool.address(0, 0);
364+
if let Some(apt) = AuthPassthrough::from_pool_settings(&pool.settings) {
365+
let hash = apt.fetch_hash(address).await?;
366+
367+
return Ok(hash);
368+
}
369+
370+
Err(Error::ClientError(format!(
371+
"Could not obtain hash for {{ username: {:?}, database: {:?} }}. Auth passthrough not enabled.",
372+
address.username, address.database
373+
)))
374+
}
375+
362376
impl<S, T> Client<S, T>
363377
where
364378
S: tokio::io::AsyncRead + std::marker::Unpin,
@@ -492,14 +506,68 @@ where
492506
}
493507
};
494508

495-
// Compare server and client hashes.
496-
let password_hash = md5_hash_password(username, &pool.settings.user.password, &salt);
509+
// Obtain the hash to compare, we give preference to that written in cleartext in config
510+
// if there is nothing set in cleartext and auth passthrough (auth_query) is configured, we use the hash obtained
511+
// when the pool was created. If there is no hash there, we try to fetch it one more time.
512+
let password_hash = if let Some(password) = &pool.settings.user.password {
513+
Some(md5_hash_password(username, password, &salt))
514+
} else {
515+
if !get_config().is_auth_query_configured() {
516+
return Err(Error::ClientError(format!("Client auth not possible, no cleartext password set for username: {:?} in config and auth passthrough (query_auth) is not set up.", username)));
517+
}
497518

498-
if password_hash != password_response {
499-
warn!("Invalid password {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}", username, pool_name, application_name);
500-
wrong_password(&mut write, username).await?;
519+
let mut hash = (*pool.auth_hash.read()).clone();
501520

502-
return Err(Error::ClientError(format!("Invalid password {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}", username, pool_name, application_name)));
521+
if hash.is_none() {
522+
warn!("Query auth configured but no hash password found for pool {}. Will try to refetch it.", pool_name);
523+
match refetch_auth_hash(&pool).await {
524+
Ok(fetched_hash) => {
525+
warn!("Password for {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}, obtained. Updating.", username, pool_name, application_name);
526+
{
527+
let mut pool_auth_hash = pool.auth_hash.write();
528+
*pool_auth_hash = Some(fetched_hash.clone());
529+
}
530+
531+
hash = Some(fetched_hash);
532+
}
533+
Err(err) => {
534+
return Err(
535+
Error::ClientError(
536+
format!("No cleartext password set, and no auth passthrough could not obtain the hash from server for {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}, the error was: {:?}",
537+
username,
538+
pool_name,
539+
application_name,
540+
err)
541+
)
542+
);
543+
}
544+
}
545+
};
546+
547+
Some(md5_hash_second_pass(&hash.unwrap(), &salt))
548+
};
549+
550+
// Once we have the resulting hash, we compare with what the client gave us.
551+
// If they do not match and auth query is set up, we try to refetch the hash one more time
552+
// to see if the password has changed since the pool was created.
553+
//
554+
// @TODO: we could end up fetching again the same password twice (see above).
555+
if password_hash.unwrap() != password_response {
556+
warn!("Invalid password {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}, will try to refetch it.", username, pool_name, application_name);
557+
let fetched_hash = refetch_auth_hash(&pool).await?;
558+
let new_password_hash = md5_hash_second_pass(&fetched_hash, &salt);
559+
560+
// Ok password changed in server an auth is possible.
561+
if new_password_hash == password_response {
562+
warn!("Password for {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}, changed in server. Updating.", username, pool_name, application_name);
563+
{
564+
let mut pool_auth_hash = pool.auth_hash.write();
565+
*pool_auth_hash = Some(fetched_hash);
566+
}
567+
} else {
568+
wrong_password(&mut write, username).await?;
569+
return Err(Error::ClientError(format!("Invalid password {{ username: {:?}, pool_name: {:?}, application_name: {:?} }}", username, pool_name, application_name)));
570+
}
503571
}
504572

505573
let transaction_mode = pool.settings.pool_mode == PoolMode::Transaction;

0 commit comments

Comments
 (0)