diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f88b8fcc..82cc3319 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ permissions: jobs: test_go: - name: Test + name: Test (go) strategy: fail-fast: false matrix: @@ -33,7 +33,7 @@ jobs: cache: true - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - run: | - make test + make test-go - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 if: ${{ always() }} with: @@ -66,6 +66,39 @@ jobs: - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8 with: args: --timeout 5000s + test_rs: + name: Test (rust) + strategy: + fail-fast: false + matrix: + platform: + - ubuntu-24.04 + - ubuntu-24.04-arm + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5 + - run: | + sudo apt-get update + sudo apt-get install -y libpam0g-dev libudev-dev + - name: Setup rust dependencies + uses: taiki-e/install-action@80a23c5ba9e1100fd8b777106e810018ed662a7b # v2 + with: + tool: cargo-llvm-cov nextest + - run: make test-rs + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 + if: ${{ always() }} + with: + use_oidc: true + flags: go-${{ matrix.platform }} + files: ${{ github.workspace }}/coverage-rs.txt + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 + if: ${{ always() }} + with: + use_oidc: true + flags: go-${{ matrix.platform }} + files: ${{ github.workspace }}/coverage-rs.txt + report_type: test_results lint_rs: name: Lint (rust) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 00108531..8990e5e7 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ terraform.rc terraform examples_local coverage.* +coverage-rs.* coverage_in_container.* junit.xml authentik_main diff --git a/Makefile b/Makefile index f67517d5..0648f8a8 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,9 @@ lint: $(foreach target,$(TARGETS),${target}/lint) "$(MAKE)" lint-rs "$(MAKE)" lint-go -test: +test: test-go test-rs + +test-go: go test \ -p 1 \ -v \ @@ -75,14 +77,25 @@ test: -out ${PWD}/junit.xml \ -set-exit-code +test-rs: + cargo llvm-cov \ + --no-report nextest \ + --workspace + cargo llvm-cov report \ + --codecov \ + --output-path "${PWD}/coverage-rs.txt" + cargo llvm-cov report \ + --html \ + --output-dir "${PWD}/cache/utils_rs/coverage" + test-integration: - "$(MAKE)" test GO_TEST_FLAGS=-tags=integration + "$(MAKE)" test-go GO_TEST_FLAGS=-tags=integration test-e2e: containers/e2e/local-build - "$(MAKE)" test GO_TEST_FLAGS=-tags=e2e - "$(MAKE)" test-e2e-convert + "$(MAKE)" test-go GO_TEST_FLAGS=-tags=e2e + "$(MAKE)" test-e2e-go-convert -test-e2e-convert: +test-e2e-go-convert: go tool covdata textfmt \ -i $(shell find ${PWD}/e2e/coverage/ -mindepth 1 -type d | xargs | sed 's/ /,/g') \ --pkg $(shell go list ./... | grep -v goauthentik.io/platform/vnd | grep -v goauthentik.io/platform/pkg/pb | xargs | sed 's/ /,/g') \ diff --git a/pam/src/auth.rs b/pam/src/auth.rs index b6d532fb..570d5e6b 100644 --- a/pam/src/auth.rs +++ b/pam/src/auth.rs @@ -26,6 +26,22 @@ pub mod token; pub const PW_PREFIX: &str = "\u{200b}"; pub const PW_PROMPT: &str = "authentik Password: "; +#[derive(Debug, PartialEq, Eq)] +enum AuthenticationAttempt { + Interactive, + Token(String), +} + +fn parse_authentication_attempt(password: &str) -> AuthenticationAttempt { + match password.strip_prefix(PW_PREFIX) { + Some(raw_token) => AuthenticationAttempt::Token(raw_token.to_owned()), + None => AuthenticationAttempt::Interactive, + } +} + +#[cfg(test)] +mod tests; + pub fn authenticate_impl( pamh: &mut PamHandle, _args: Vec<&CStr>, @@ -94,29 +110,31 @@ pub fn authenticate_impl( } }; - if password.starts_with(PW_PREFIX) { - log::debug!("Token authentication"); - let raw_token = password.replace(PW_PREFIX, ""); - let decoded = match decode_pb::(raw_token) { - Ok(t) => t, - Err(e) => { - log::warn!("failed to decode token: {}", e); - return PamResultCode::PAM_ABORT; - } - }; - let token_res = match auth_token(username, decoded.token.to_owned(), bridge) { - Ok(t) => t, - Err(e) => return e, - }; - session_data.local_socket = decoded.local_socket; - session_id = token_res.session_id; - } else { - log::debug!("Interactive authentication"); - let int_res = match auth_interactive(username, password.to_owned(), &conv, bridge) { - Ok(ss) => ss, - Err(code) => return code, - }; - session_id = int_res.session_id; + match parse_authentication_attempt(password) { + AuthenticationAttempt::Token(raw_token) => { + log::debug!("Token authentication"); + let decoded = match decode_pb::(raw_token) { + Ok(t) => t, + Err(e) => { + log::warn!("failed to decode token: {}", e); + return PamResultCode::PAM_ABORT; + } + }; + let token_res = match auth_token(username, decoded.token.to_owned(), bridge) { + Ok(t) => t, + Err(e) => return e, + }; + session_data.local_socket = decoded.local_socket; + session_id = token_res.session_id; + } + AuthenticationAttempt::Interactive => { + log::debug!("Interactive authentication"); + let int_res = match auth_interactive(username, password.to_owned(), &conv, bridge) { + Ok(ss) => ss, + Err(code) => return code, + }; + session_id = int_res.session_id; + } } if !session_data.local_socket.is_empty() { pam_try_log!( diff --git a/pam/src/auth/interactive.rs b/pam/src/auth/interactive.rs index dfa9dc1c..5a19e95f 100644 --- a/pam/src/auth/interactive.rs +++ b/pam/src/auth/interactive.rs @@ -39,6 +39,10 @@ pub fn prompt_meta_to_pam_message_style(challenge: &InteractiveChallenge) -> Pam } } +#[cfg(test)] +#[path = "interactive_tests.rs"] +mod tests; + pub fn auth_interactive( username: String, password: String, diff --git a/pam/src/auth/interactive_tests.rs b/pam/src/auth/interactive_tests.rs new file mode 100644 index 00000000..e7a42af0 --- /dev/null +++ b/pam/src/auth/interactive_tests.rs @@ -0,0 +1,82 @@ +use authentik_sys::generated::sys_auth::{ + InteractiveAuthResult, InteractiveChallenge, interactive_challenge::PromptMeta, +}; +use pam::constants::{ + PAM_BINARY_PROMPT, PAM_ERROR_MSG, PAM_PROMPT_ECHO_OFF, PAM_PROMPT_ECHO_ON, PAM_RADIO_TYPE, + PAM_TEXT_INFO, PamResultCode, +}; + +use super::{prompt_meta_to_pam_message_style, result_to_pam_result}; + +fn challenge(prompt_meta: PromptMeta) -> InteractiveChallenge { + InteractiveChallenge { + txid: String::new(), + finished: false, + result: 0, + prompt: String::new(), + prompt_meta: prompt_meta as i32, + debug_info: String::new(), + session_id: String::new(), + component: String::new(), + } +} + +#[test] +fn maps_interactive_results_to_pam_codes() { + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamSuccess as i32), + PamResultCode::PAM_SUCCESS + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamPermDenied as i32), + PamResultCode::PAM_PERM_DENIED + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamAuthErr as i32), + PamResultCode::PAM_AUTH_ERR + ); +} + +#[test] +fn falls_back_to_system_error_for_unknown_results() { + assert_eq!(result_to_pam_result(999), PamResultCode::PAM_SYSTEM_ERR); +} + +#[test] +fn maps_prompt_meta_values_to_pam_styles() { + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamBinaryPrompt)), + PAM_BINARY_PROMPT + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamErrorMsg)), + PAM_ERROR_MSG + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOff)), + PAM_PROMPT_ECHO_OFF + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOn)), + PAM_PROMPT_ECHO_ON + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamRadioType)), + PAM_RADIO_TYPE + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamTextInfo)), + PAM_TEXT_INFO + ); +} + +#[test] +fn defaults_to_echo_off_for_unknown_prompt_meta() { + let mut unknown = challenge(PromptMeta::Unspecified); + unknown.prompt_meta = 999; + + assert_eq!( + prompt_meta_to_pam_message_style(&unknown), + PAM_PROMPT_ECHO_OFF + ); +} diff --git a/pam/src/auth/tests.rs b/pam/src/auth/tests.rs new file mode 100644 index 00000000..dbce9111 --- /dev/null +++ b/pam/src/auth/tests.rs @@ -0,0 +1,27 @@ +use super::{AuthenticationAttempt, PW_PREFIX, parse_authentication_attempt}; + +#[test] +fn parses_interactive_passwords_without_prefix() { + assert_eq!( + parse_authentication_attempt("plain-password"), + AuthenticationAttempt::Interactive + ); +} + +#[test] +fn parses_prefixed_passwords_as_tokens() { + assert_eq!( + parse_authentication_attempt(&format!("{PW_PREFIX}encoded-token")), + AuthenticationAttempt::Token("encoded-token".to_owned()) + ); +} + +#[test] +fn only_strips_the_leading_prefix_marker() { + let password = format!("{PW_PREFIX}token{PW_PREFIX}body"); + + assert_eq!( + parse_authentication_attempt(&password), + AuthenticationAttempt::Token(format!("token{PW_PREFIX}body")) + ); +} diff --git a/pam/src/auth/token.rs b/pam/src/auth/token.rs index f3417ec0..0044d227 100644 --- a/pam/src/auth/token.rs +++ b/pam/src/auth/token.rs @@ -3,6 +3,36 @@ use authentik_sys::generated::sys_auth::{TokenAuthRequest, TokenAuthResponse}; use authentik_sys::grpc::SysdBridge; use pam::constants::PamResultCode; +fn validate_token_auth_response( + username: &str, + response: &TokenAuthResponse, +) -> Result<(), PamResultCode> { + if !response.successful { + return Err(PamResultCode::PAM_AUTH_ERR); + } + + let token_username = response + .token + .as_ref() + .ok_or(PamResultCode::PAM_AUTH_ERR)? + .preferred_username + .as_str(); + if username != token_username { + log::warn!( + "User mismatch: token={:#?}, expected={:#?}", + token_username, + username + ); + return Err(PamResultCode::PAM_USER_UNKNOWN); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "token_tests.rs"] +mod tests; + pub fn auth_token( username: String, token: String, @@ -23,23 +53,7 @@ pub fn auth_token( } }; - if !response.successful { - return Err(PamResultCode::PAM_AUTH_ERR); - } - + validate_token_auth_response(&username, &response)?; log::debug!("Got valid token: {response:#?}"); - let token_username = response - .token - .clone() - .ok_or(PamResultCode::PAM_AUTH_ERR)? - .preferred_username; - if username != token_username { - log::warn!( - "User mismatch: token={:#?}, expected={:#?}", - token_username, - username - ); - return Err(PamResultCode::PAM_USER_UNKNOWN); - } Ok(response) } diff --git a/pam/src/auth/token_tests.rs b/pam/src/auth/token_tests.rs new file mode 100644 index 00000000..5eb5a641 --- /dev/null +++ b/pam/src/auth/token_tests.rs @@ -0,0 +1,54 @@ +use authentik_sys::generated::agent::Token; +use authentik_sys::generated::sys_auth::TokenAuthResponse; +use pam::constants::PamResultCode; + +use super::validate_token_auth_response; + +fn response(successful: bool, preferred_username: Option<&str>) -> TokenAuthResponse { + TokenAuthResponse { + successful, + token: preferred_username.map(|username| Token { + preferred_username: username.to_owned(), + iss: String::new(), + sub: String::new(), + aud: Vec::new(), + exp: None, + nbf: None, + iat: None, + jti: String::new(), + }), + session_id: "session-id".to_owned(), + } +} + +#[test] +fn rejects_unsuccessful_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(false, Some("alice"))), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn rejects_missing_token_payloads() { + assert_eq!( + validate_token_auth_response("alice", &response(true, None)), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn rejects_tokens_for_different_users() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("bob"))), + Err(PamResultCode::PAM_USER_UNKNOWN) + ); +} + +#[test] +fn accepts_valid_token_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("alice"))), + Ok(()) + ); +} diff --git a/pam/src/session_data.rs b/pam/src/session_data.rs index 6da64178..48c33ba0 100644 --- a/pam/src/session_data.rs +++ b/pam/src/session_data.rs @@ -3,59 +3,43 @@ use pam::constants::PamResultCode; use serde::{Deserialize, Serialize}; use std::fs::{File, Permissions, remove_file}; -use std::io::Write; +use std::io::{Read, Write}; use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SessionData { pub username: String, pub token: String, pub local_socket: String, } -pub fn _session_file(id: String) -> String { - format!("/tmp/.aksm-{id}") +pub fn session_file_path_for(base_dir: impl AsRef, id: &str) -> PathBuf { + base_dir.as_ref().join(format!(".aksm-{id}")) } -pub fn _read_session_data(id: String) -> Result { - let path = _session_file(id); - let file = match File::open(path) { - Ok(f) => f, - Err(e) => { - log::warn!("failed to open file: {e}"); - return Err(PamResultCode::PAM_SESSION_ERR); - } - }; +pub fn _session_file(id: String) -> String { + session_file_path_for("/tmp", &id) + .to_string_lossy() + .into_owned() +} - match serde_json::from_reader(file) { - Ok(t) => Ok(t), - Err(e) => { - log::warn!("failed to write session data: {e}"); - Err(PamResultCode::PAM_AUTH_ERR) - } - } +fn serialize_session_data(data: &SessionData) -> Result { + serde_json::to_string(data).map_err(|e| { + log::warn!("failed to json encode: {e}"); + PamResultCode::PAM_SESSION_ERR + }) } -pub fn _delete_session_data(id: String) -> Result<(), PamResultCode> { - let path = _session_file(id); - match remove_file(path) { - Ok(_) => Ok(()), - Err(e) => { - log::warn!("Failed to remove session data: {e}"); - Err(PamResultCode::PAM_SESSION_ERR) - } - } +fn read_session_data(reader: impl Read) -> Result { + serde_json::from_reader(reader).map_err(|e| { + log::warn!("failed to decode session data: {e}"); + PamResultCode::PAM_AUTH_ERR + }) } -pub fn _write_session_data(id: String, data: SessionData) -> Result<(), PamResultCode> { - let json_data = match serde_json::to_string(&data) { - Ok(j) => j, - Err(e) => { - log::warn!("failed to json encode: {e}"); - return Err(PamResultCode::PAM_SESSION_ERR); - } - }; - let path = _session_file(id); +fn write_session_data_file(path: &Path, data: &SessionData) -> Result<(), PamResultCode> { + let json_data = serialize_session_data(data)?; let mut file = match File::create(path) { Ok(f) => f, Err(e) => { @@ -80,3 +64,36 @@ pub fn _write_session_data(id: String, data: SessionData) -> Result<(), PamResul } } } + +pub fn _read_session_data(id: String) -> Result { + let path = session_file_path_for("/tmp", &id); + let file = match File::open(path) { + Ok(f) => f, + Err(e) => { + log::warn!("failed to open file: {e}"); + return Err(PamResultCode::PAM_SESSION_ERR); + } + }; + + read_session_data(file) +} + +pub fn _delete_session_data(id: String) -> Result<(), PamResultCode> { + let path = session_file_path_for("/tmp", &id); + match remove_file(path) { + Ok(_) => Ok(()), + Err(e) => { + log::warn!("Failed to remove session data: {e}"); + Err(PamResultCode::PAM_SESSION_ERR) + } + } +} + +pub fn _write_session_data(id: String, data: SessionData) -> Result<(), PamResultCode> { + let path = session_file_path_for("/tmp", &id); + write_session_data_file(&path, &data) +} + +#[cfg(test)] +#[path = "session_data_tests.rs"] +mod tests; diff --git a/pam/src/session_data_tests.rs b/pam/src/session_data_tests.rs new file mode 100644 index 00000000..c27f9b6f --- /dev/null +++ b/pam/src/session_data_tests.rs @@ -0,0 +1,65 @@ +use super::{ + SessionData, read_session_data, serialize_session_data, session_file_path_for, + write_session_data_file, +}; +use pam::constants::PamResultCode; +use std::fs; +use std::io::Cursor; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn sample_session_data() -> SessionData { + SessionData { + username: "alice".to_owned(), + token: "token".to_owned(), + local_socket: "/tmp/socket".to_owned(), + } +} + +#[test] +fn builds_session_paths_in_the_requested_directory() { + assert_eq!( + session_file_path_for("/var/tmp", "abc123"), + PathBuf::from("/var/tmp/.aksm-abc123") + ); +} + +#[test] +fn round_trips_session_data_through_json() { + let data = sample_session_data(); + let encoded = serialize_session_data(&data).expect("should serialize"); + + let decoded = read_session_data(Cursor::new(encoded)).expect("should deserialize"); + + assert_eq!(decoded.username, data.username); + assert_eq!(decoded.token, data.token); + assert_eq!(decoded.local_socket, data.local_socket); +} + +#[test] +fn rejects_invalid_session_json() { + assert_eq!( + read_session_data(Cursor::new("{not-json")), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn writes_session_data_with_read_only_permissions() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("authentik-pam-test-{unique}")); + fs::create_dir(&dir).expect("should create temp dir"); + let path = dir.join(".aksm-session"); + + write_session_data_file(&path, &sample_session_data()).expect("should write session file"); + + let metadata = fs::metadata(&path).expect("should stat session file"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o400); + + fs::remove_file(&path).expect("should clean up session file"); + fs::remove_dir(&dir).expect("should clean up temp dir"); +}