feat: password change (+)

This works, however there's still stuff to do.

The configuration.nix pam stack is broken, still defaults back to
pam_unix.so if this fails.

The code is now a small mess, I need to make `Client` structs and more
separation.

And I still have to fully understand keycloak and properly use the
clients or configure them.
This commit is contained in:
2026-05-10 18:48:51 +02:00
parent 0cf366d8ff
commit bf952a5dc4
5 changed files with 496 additions and 60 deletions
+23 -1
View File
@@ -18,12 +18,34 @@ You can `cargo doc` to find the documentation for this at `keycloak_pam > args >
With the purpose of being tiny and minimal, the module uses libcurl for the few requests it makes, so HTTP/S and all networking behavior is offloaded to it. With the purpose of being tiny and minimal, the module uses libcurl for the few requests it makes, so HTTP/S and all networking behavior is offloaded to it.
This is also made in rust, which means default `String` and `&str` are guaranteed to be UTF-8 encoded and invalid UTF-8 input might fail. I did try to use OS and C strings for all input, but I recommend still avoiding non-UTF8 input. This is also made in rust, which means default `String` and `&str` are guaranteed to be UTF-8 encoded and invalid UTF-8 input might fail. I did try to use OS and C strings for all input, but I recommend still avoiding non-UTF8 input. Password change needs to convert to `String` at some point and UTF-8 input is required.
The code should **NEVER** panic (aside from memory management issues), no `unwrap`, `expect` or such methods should ever be used, slice indexing is avoided and errors are gracefully managed. The code should **NEVER** panic (aside from memory management issues), no `unwrap`, `expect` or such methods should ever be used, slice indexing is avoided and errors are gracefully managed.
To check the group, keycloak-pam uses libc's `getgrnam_r`, this takes a buffer we hardcoded to 4 KiB, if there happens to be a group bigger than this, keycloak-pam won't be able to handle and will fail explaining this. To check the group, keycloak-pam uses libc's `getgrnam_r`, this takes a buffer we hardcoded to 4 KiB, if there happens to be a group bigger than this, keycloak-pam won't be able to handle and will fail explaining this.
# Policy
Keycloak can be configured in a lot of ways, so it's important to understand how this module will interact with Keylcoak. Otherwise misconfiguration can allow anyone to login as another user.
Think of this as stages, where the first one for all actions is:
Must check the subject user against the configured group. If it's not a member, it will just fallback to the next PAM module.
Here things differ a bit from login to password change:
## Login
This is simple, will only allow login if the password used to log in is not temporary, any error will fail with an error.
The client used for this is still not definitive, currently it uses a client ID and client secret for this, `account` by default, but this might change.
## Registration
Even then, a user might and should be able to login if their account is not set up (e.g. through ssh).
If they try to change their password without having one set up, it won't ask for their current one and will use the admin REST API to set the first one, this one won't be a temporary password and will fully set up their accounts.
# Behavior # Behavior
To authenticate users, the module uses the endpoint `{BASE_URL}/realms/{realm}/protocol/openid-connect/token` as a configured client. This needs a client ID and its secret that you need to get and configure per realm. To authenticate users, the module uses the endpoint `{BASE_URL}/realms/{realm}/protocol/openid-connect/token` as a configured client. This needs a client ID and its secret that you need to get and configure per realm.
+42 -5
View File
@@ -1,6 +1,6 @@
use std::fmt::Display; use std::fmt;
use serde::Deserialize; use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, thiserror::Error)] #[derive(Debug, Deserialize, thiserror::Error)]
pub struct AuthError { pub struct AuthError {
@@ -8,14 +8,51 @@ pub struct AuthError {
error_description: Option<String>, error_description: Option<String>,
} }
impl Display for AuthError { impl fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let error = &self.error; let error = &self.error;
if let Some(ref error_description) = self.error_description { if let Some(ref error_description) = self.error_description {
writeln!(f, "authentication type '{error}' with message '{error_description:?}'") writeln!(
f,
"authentication type '{error}' with message '{error_description:?}'"
)
} else { } else {
writeln!(f, "authentication type '{error}'") writeln!(f, "authentication type '{error}'")
} }
} }
} }
#[derive(Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u32,
pub token_type: String,
pub scope: String,
}
#[derive(Debug, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct Password {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
#[serde(rename = "credentialData")]
pub credential_data: String,
}
#[derive(Serialize)]
pub struct ResetPasswordRequest<'a> {
pub temporary: bool, // false
/// Must be "password" for this scheme.
#[serde(rename = "type")]
pub type_: &'static str, // "password"
pub value: &'a str, // "testpasswordgoeshere"
}
+232 -14
View File
@@ -1,16 +1,21 @@
use std::{ use std::{
ffi::{CStr, CString, OsStr}, ffi::{CStr, CString, OsStr},
fmt,
fs::File, fs::File,
io::{self}, io::{self, Cursor, Read as _},
os::unix::ffi::OsStrExt as _, os::unix::ffi::OsStrExt as _,
}; };
use curl::easy::{Easy, List}; use curl::easy::{Easy, List};
use serde::Deserialize; use serde::Deserialize;
use crate::{ext::CStrExt as _, libc as mylibc}; use crate::{
ext::{CStrExt as _, StrExt},
keycloak::api::TokenResponse,
libc as mylibc,
};
mod api; pub mod api;
#[allow(clippy::unsafe_derive_deserialize)] // seriously? -_- #[allow(clippy::unsafe_derive_deserialize)] // seriously? -_-
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -20,6 +25,8 @@ pub struct KeycloakAPI {
client_id: String, client_id: String,
client_secret: String, client_secret: String,
unix_group: CString, unix_group: CString,
admin_username: String,
admin_password: String,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -30,6 +37,28 @@ pub enum LoadError {
ParseConfig(#[from] serde_json::Error), ParseConfig(#[from] serde_json::Error),
} }
#[derive(Debug, thiserror::Error)]
pub enum GetCredentialsError {
#[error("on request: {0}")]
Fetch(#[from] curl::Error),
#[error("unexpected API response: {0}")]
InvalidAPIResponse(#[from] serde_json::Error),
#[error("expected 1 result, found none or more")]
NonUnitResults,
#[error("server error response")]
ErrorResponse,
}
#[derive(Debug, thiserror::Error)]
pub enum SetCredentialsError {
#[error("on request: {0}")]
Fetch(#[from] curl::Error),
#[error("serializing request body")]
SerializeError(#[from] serde_json::Error),
#[error("server error response")]
ErrorResponse,
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum LoginError { pub enum LoginError {
#[error("on POST request: {0}")] #[error("on POST request: {0}")]
@@ -41,17 +70,74 @@ pub enum LoginError {
InvalidLogin(#[from] api::AuthError), InvalidLogin(#[from] api::AuthError),
} }
fn curl_request(uri: &str, body: &[u8], header: &str) -> Result<(u32, Vec<u8>), curl::Error> { #[derive(Clone)]
let mut easy = Easy::new(); pub enum Method<'a> {
easy.post(true)?; GET,
POST(Option<&'a [u8]>),
PUT(Option<Box<[u8]>>),
}
let mut headers = List::new(); impl fmt::Debug for Method<'_> {
headers.append(header)?; fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
easy.http_headers(headers)?; match self {
Self::GET => write!(f, "GET"),
Self::POST(None) => write!(f, "POST"),
Self::POST(Some(_)) => write!(f, "POST<body>"),
Self::PUT(None) => write!(f, "PUT"),
Self::PUT(Some(_)) => write!(f, "PUT<body>"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CurlStatus(u32);
impl CurlStatus {
const fn is_ok(self) -> bool {
matches!(self.0, 200..300)
}
}
fn curl_request(
method: Method,
uri: &str,
headers: &[&str],
debug: bool,
) -> Result<(CurlStatus, Vec<u8>), curl::Error> {
if debug {
eprintln!("{method:?} {uri} -H \"{headers:?}\"");
}
let mut easy = Easy::new();
let mut curl_headers = List::new();
for header in headers {
curl_headers.append(header)?;
}
easy.http_headers(curl_headers)?;
easy.url(uri)?; easy.url(uri)?;
match method {
Method::GET => {
easy.get(true)?;
}
Method::POST(body) => {
easy.post(true)?;
if let Some(body) = body {
easy.post_fields_copy(body)?; easy.post_fields_copy(body)?;
}
}
Method::PUT(body) => {
easy.put(true)?;
if let Some(body) = body {
easy.upload(true)?;
easy.in_filesize(body.len() as u64)?;
let mut cur = Cursor::new(body);
easy.read_function(move |buf| Ok(cur.read(buf).unwrap_or(0)))?;
}
}
}
let mut response_data = Vec::new(); let mut response_data = Vec::new();
{ {
@@ -64,7 +150,7 @@ fn curl_request(uri: &str, body: &[u8], header: &str) -> Result<(u32, Vec<u8>),
transfer.perform()?; transfer.perform()?;
} }
Ok((easy.response_code()?, response_data)) Ok((CurlStatus(easy.response_code()?), response_data))
} }
impl KeycloakAPI { impl KeycloakAPI {
@@ -95,7 +181,35 @@ impl KeycloakAPI {
}) })
} }
pub fn check_credentials(&self, username: &OsStr, password: &OsStr) -> Result<(), LoginError> { pub fn get_admin_token(&self, debug: bool) -> Result<TokenResponse, LoginError> {
self.get_admincli_oidc_token(
self.admin_username.as_os_str(),
self.admin_password.as_os_str(),
debug,
)
}
pub fn get_admincli_oidc_token(
&self,
admin_username: &OsStr,
admin_password: &OsStr,
debug: bool,
) -> Result<TokenResponse, LoginError> {
let body = format!(
"grant_type=password&client_id=admin-cli&username={USERNAME}&password={PASSWORD}",
USERNAME = urlencoding::encode_binary(admin_username.as_bytes()),
PASSWORD = urlencoding::encode_binary(admin_password.as_bytes()),
);
self.get_token_from_body(&body, debug)
}
pub fn get_oidc_token(
&self,
username: &OsStr,
password: &OsStr,
debug: bool,
) -> Result<TokenResponse, LoginError> {
let body = format!( let body = format!(
"grant_type=password&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&username={USERNAME}&password={PASSWORD}", "grant_type=password&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&username={USERNAME}&password={PASSWORD}",
CLIENT_ID = urlencoding::encode(&self.client_id), CLIENT_ID = urlencoding::encode(&self.client_id),
@@ -104,19 +218,123 @@ impl KeycloakAPI {
PASSWORD = urlencoding::encode_binary(password.as_bytes()), PASSWORD = urlencoding::encode_binary(password.as_bytes()),
); );
self.get_token_from_body(&body, debug)
}
pub fn get_token_from_body(
&self,
body: &str,
debug: bool,
) -> Result<TokenResponse, LoginError> {
let (status_code, body) = curl_request( let (status_code, body) = curl_request(
Method::POST(Some(body.as_bytes())),
&format!( &format!(
"{}/realms/{}/protocol/openid-connect/token", "{}/realms/{}/protocol/openid-connect/token",
self.base_url, self.default_realm, self.base_url, self.default_realm,
), ),
body.as_bytes(), &[
"Content-Type: application/x-www-form-urlencoded", "Content-Type: application/x-www-form-urlencoded",
"Accept: application/json",
],
debug,
)?; )?;
if status_code == 200 { if status_code.is_ok() {
Ok(()) Ok(serde_json::from_slice(&body)?)
} else { } else {
Err(LoginError::InvalidLogin(serde_json::from_slice(&body)?)) Err(LoginError::InvalidLogin(serde_json::from_slice(&body)?))
} }
} }
pub fn get_user_credentials(
&self,
username: &str,
admin_token: &str,
debug: bool,
) -> Result<(String, Vec<api::Password>), GetCredentialsError> {
let (status_code, body) = curl_request(
Method::GET,
&format!(
"{}/admin/realms/{}/users?username={username}",
self.base_url, self.default_realm
),
&[&format!("Authorization: Bearer {admin_token}")],
debug,
)?;
if !status_code.is_ok() {
if debug {
eprintln!("got error: {}", String::from_utf8_lossy(&body));
}
return Err(GetCredentialsError::ErrorResponse);
}
let mut found_users: Vec<api::User> = serde_json::from_slice(&body)?;
if debug {
eprintln!("matched users = {found_users:#?}");
}
let [first_user] = &mut found_users[..] else {
return Err(GetCredentialsError::NonUnitResults);
};
let (status_code, body) = curl_request(
Method::GET,
&format!(
"{}/admin/realms/{}/users/{}/credentials",
self.base_url, self.default_realm, first_user.id
),
&[
&format!("Authorization: Bearer {admin_token}"),
"Accept: application/json",
],
debug,
)?;
if !status_code.is_ok() {
if debug {
eprintln!("got error: {}", String::from_utf8_lossy(&body));
}
return Err(GetCredentialsError::ErrorResponse);
}
Ok((
std::mem::take(&mut first_user.id),
serde_json::from_slice(&body)?,
))
}
pub fn set_user_credentials(
&self,
user_id: &str,
new_password: &str,
admin_token: &str,
debug: bool,
) -> Result<(), SetCredentialsError> {
let request_body = serde_json::to_vec(&api::ResetPasswordRequest {
temporary: false,
type_: "password",
value: new_password,
})
.map_err(SetCredentialsError::SerializeError)?;
let (status_code, body) = curl_request(
Method::PUT(Some(request_body.into_boxed_slice())),
&format!(
// "http://127.0.0.1:3000/admin/realms/master/users/79149d47-17e6-42be-9fd8-47b04127f162/reset-password",
"{}/admin/realms/{}/users/{user_id}/reset-password",
self.base_url, self.default_realm
),
&[
&format!("Authorization: Bearer {admin_token}"),
"Content-Type: application/json",
],
debug,
)?;
if !status_code.is_ok() {
if debug {
eprintln!("got error: {}", String::from_utf8_lossy(&body));
}
return Err(SetCredentialsError::ErrorResponse);
}
Ok(())
}
} }
+143 -18
View File
@@ -1,6 +1,7 @@
//! Privileged PAM module. //! Privileged PAM module.
use std::ffi::CStr; use std::ffi::{CStr, OsStr};
use std::os::unix::ffi::OsStrExt as _;
use nonstick::{ErrorCode, ModuleClient, PamModule, pam_export}; use nonstick::{ErrorCode, ModuleClient, PamModule, pam_export};
@@ -26,12 +27,13 @@ macro_rules! error_print {
if let Err(ref error) = expr if let Err(ref error) = expr
&& $if_print && $if_print
{ {
eprintln!("keycloak-pam: {error}"); eprintln!("keycloak-pam ({}): {error}", line!());
}; };
expr expr
}}; }};
} }
// TODO: turns out nonstick has great resources explaining sensible return errors, double check
/// Errors: /// Errors:
/// ///
/// - [`ErrorCode::ConversationError`]: Something wrong converting types (e.g. UTF-8) /// - [`ErrorCode::ConversationError`]: Something wrong converting types (e.g. UTF-8)
@@ -49,9 +51,9 @@ impl<M: ModuleClient> PamModule<M> for KeycloakPAM {
use ErrorCode as E; use ErrorCode as E;
let args = Args::parse(&args); let args = Args::parse(&args);
if args.debug {
let username = handle.username(None)?; eprintln!("starting authentication...");
let password = handle.authtok(None)?; }
let no_silent_flag = (flags & nonstick::AuthnFlags::SILENT).is_empty(); let no_silent_flag = (flags & nonstick::AuthnFlags::SILENT).is_empty();
let can_print = no_silent_flag | args.debug; let can_print = no_silent_flag | args.debug;
@@ -60,18 +62,109 @@ impl<M: ModuleClient> PamModule<M> for KeycloakPAM {
let api_client = KeycloakAPI::load_from_configuration(args.config.to_os_str()); let api_client = KeycloakAPI::load_from_configuration(args.config.to_os_str());
let api_client = error_print!(api_client, can_print).map_err(|_| E::ServiceError)?; let api_client = error_print!(api_client, can_print).map_err(|_| E::ServiceError)?;
let username = handle.username(None)?;
if args.debug {
eprintln!("authenticating {}", username.to_string_lossy());
}
let should_skip = error_print!(api_client.should_skip(&username), can_print) let should_skip = error_print!(api_client.should_skip(&username), can_print)
.map_err(|_| E::ServiceError)?; .map_err(|_| E::ServiceError)?;
if should_skip { if should_skip {
return Err(E::PermissionDenied); return Err(E::PermissionDenied);
} }
let checked_credentials = api_client.check_credentials(username.as_os_str(), &password); let password = handle.authtok(None)?;
error_print!(checked_credentials, can_print).map_err(|error| match error {
keycloak::LoginError::Fetch(_) | keycloak::LoginError::InvalidAPIResponse(_) => { Self::get_token_for(
E::AuthInfoUnavailable &api_client,
username.as_os_str(),
&password,
can_print,
args.debug,
)
.map(|_| ())
} }
keycloak::LoginError::InvalidLogin(_) => E::AuthenticationError, }
// TODO: check if another user can call passwd on another user without passwd and skip auth
fn change_authtok(
handle: &mut M,
args: Vec<&std::ffi::CStr>,
action: nonstick::AuthtokAction,
flags: nonstick::AuthtokFlags,
) -> nonstick::Result<()> {
use ErrorCode as E;
let args = Args::parse(&args);
if args.debug {
eprintln!("starting authtok change...");
}
let no_silent_flag = (flags & nonstick::AuthtokFlags::SILENT).is_empty();
let can_print = no_silent_flag | args.debug;
{
let api_client = KeycloakAPI::load_from_configuration(args.config.to_os_str());
let api_client = error_print!(api_client, can_print).map_err(|_| E::ServiceError)?;
let username = handle.username(None)?;
let username_str = str::from_utf8(username.as_bytes()).map_err(|_| E::BufferError)?;
let should_skip = error_print!(api_client.should_skip(&username), can_print)
.map_err(|_| E::ServiceError)?;
if should_skip {
return Err(E::Ignore);
}
if action != nonstick::AuthtokAction::Update {
if args.debug {
eprintln!("not update, skipping...");
}
// *should* be valid
return Ok(());
}
let admin_token = error_print!(api_client.get_admin_token(args.debug), can_print)
.map_err(|error| match error {
keycloak::LoginError::Fetch(_)
| keycloak::LoginError::InvalidAPIResponse(_) => E::AuthInfoUnavailable,
keycloak::LoginError::InvalidLogin(_) => E::ServiceError,
})?;
if args.debug {
eprintln!("verifying credentials for {username_str}");
}
let (user_id, user_creds) = Self::get_creds_for(
&api_client,
username_str,
&admin_token.access_token,
can_print,
args.debug,
)?;
if !user_creds.is_empty() {
let old_authtok = handle.old_authtok(None)?;
Self::get_token_for(&api_client, &username, &old_authtok, can_print, args.debug)?;
if args.debug {
eprintln!("identity verified");
}
}
let authtok = handle.authtok(None)?;
let authtok_str = str::from_utf8(authtok.as_bytes()).map_err(|_| E::BufferError)?;
error_print!(
api_client.set_user_credentials(
&user_id,
authtok_str,
&admin_token.access_token,
args.debug
),
can_print
)
.map_err(|error| match error {
keycloak::SetCredentialsError::Fetch(_) => E::CredentialsUnavailable,
keycloak::SetCredentialsError::SerializeError(_) => E::BufferError,
keycloak::SetCredentialsError::ErrorResponse => E::ServiceError,
}) })
} }
} }
@@ -83,14 +176,46 @@ impl<M: ModuleClient> PamModule<M> for KeycloakPAM {
// ) -> nonstick::Result<()> { // ) -> nonstick::Result<()> {
// todo!() // todo!()
// } // }
}
fn change_authtok( impl KeycloakPAM {
handle: &mut M, /// Can also be used to check the credentials
args: Vec<&std::ffi::CStr>, fn get_token_for(
action: nonstick::AuthtokAction, api_client: &keycloak::KeycloakAPI,
flags: nonstick::AuthtokFlags, username: &OsStr,
) -> nonstick::Result<()> { password: &OsStr,
_ = (handle, args, action, flags); can_print: bool,
todo!() debug: bool,
) -> nonstick::Result<keycloak::api::TokenResponse> {
use ErrorCode as E;
let checked_credentials = api_client.get_oidc_token(username, password, debug);
error_print!(checked_credentials, can_print).map_err(|error| match error {
keycloak::LoginError::Fetch(_) | keycloak::LoginError::InvalidAPIResponse(_) => {
E::AuthInfoUnavailable
}
keycloak::LoginError::InvalidLogin(_) => E::AuthenticationError,
})
}
fn get_creds_for(
api_client: &keycloak::KeycloakAPI,
username: &str,
admin_token: &str,
can_print: bool,
debug: bool,
) -> nonstick::Result<(String, Vec<keycloak::api::Password>)> {
use ErrorCode as E;
error_print!(
api_client.get_user_credentials(username, admin_token, debug),
can_print
)
.map_err(|error| match error {
keycloak::GetCredentialsError::Fetch(_)
| keycloak::GetCredentialsError::InvalidAPIResponse(_) => E::AuthInfoUnavailable,
keycloak::GetCredentialsError::NonUnitResults => E::CredentialsError,
keycloak::GetCredentialsError::ErrorResponse => E::ServiceError,
})
} }
} }
+51 -17
View File
@@ -104,6 +104,11 @@ in
groups.keycloak-login = { }; groups.keycloak-login = { };
}; };
environment.systemPackages = with pkgs; [
croc
neovim
];
services.keycloak = { services.keycloak = {
enable = true; enable = true;
initialAdminPassword = "dontchangeme"; initialAdminPassword = "dontchangeme";
@@ -133,31 +138,60 @@ in
security.pam.services = security.pam.services =
let let
keycloakReplacesUnix = {
rules.auth.keycloak-pam = {
order = config.security.pam.services.login.rules.auth.unix.order - 10;
control = "sufficient";
modulePath = "${packages.keycloak-pam}/lib/libkeycloak_pam.so"; modulePath = "${packages.keycloak-pam}/lib/libkeycloak_pam.so";
keycloakPamConfig = builtins.toFile "keycloak-pam.json" (
builtins.toJSON {
base_url = "http://127.0.0.1:${toString config.services.keycloak.settings.http-port}";
default_realm = "master";
client_id = "account";
client_secret = "7bsrMrRuNKpqsxcOOJavWjEqek12ZCkj";
unix_group = "keycloak-login";
admin_username = "admin";
admin_password = "dontchangeme";
}
);
type = {
auth = {
type = "auth";
control = "sufficient";
};
password = {
type = "password";
control = "sufficient";
};
};
settings = { settings = {
debug = true; debug = true;
config = keycloakPamConfig;
};
config = builtins.toFile "keycloak-pam.json" '' mkRuleBeforeUnix =
{ { type, control }:
"base_url": "http://127.0.0.1:${toString config.services.keycloak.settings.http-port}", service: {
"default_realm": "master", "${type}".keycloak-pam = {
"client_id": "account", order = config.security.pam.services."${service}".rules."${type}".unix.order - 10;
"client_secret": "7bsrMrRuNKpqsxcOOJavWjEqek12ZCkj", inherit modulePath settings control;
"unix_group": "keycloak-login"
}
'';
};
}; };
}; };
mkRulesBeforeUnix =
types: service: builtins.foldl' (rules: type: rules // mkRuleBeforeUnix type service) { } types;
mkServicesWith =
withfn: builtins.foldl' (attrs: service: attrs // { "${service}".rules = withfn service; }) { };
mkAuths = mkServicesWith (mkRuleBeforeUnix type.auth);
mkPasswds = mkServicesWith (mkRulesBeforeUnix [
type.auth
type.password
]);
in in
builtins.foldl' (attrs: service: attrs // { "${service}" = keycloakReplacesUnix; }) { } [ mkAuths [
"login" "login"
"passwd"
"su" "su"
"sudo" "sudo"
]; "passwd"
]
// mkPasswds [ "passwd" ];
} }