Here is some code I wrote to add, remove, and change user names and passwords in .htpasswd with PHP:
function adduser($user, $pass) {
try {
$htpasswd = '.htpasswd';
//$hash = '{SHA}'.crypt($pass, base64_encode($pass));
$hash = crypt_apr1_md5($pass); //APR1-MD5
$contents = $user . ':' . $hash;
$lines = explode(PHP_EOL, file_get_contents($htpasswd)); // get .htpasswd
print('<h4>input:</h4><pre>'.print_r(implode(PHP_EOL, $lines),true).'</pre>');
$exists = false;
foreach($lines as $line){
$existing_user = explode( ':', $line );
if ($existing_user[0] == $user) { //checks if user exists
$contents = str_replace($line, $contents, $lines); //changes password for user
$contents = implode(PHP_EOL, $contents);
$exists = true;
if ($pass == '') { // removes user if password is empty
$contents = str_replace($line, '', $lines); //removes user
$contents = array_filter($contents); // cleans empty space in array
$contents = implode(PHP_EOL, $contents);
$exists = true;
}
}
}
if ($exists == false) {
$contents = implode(PHP_EOL, $lines) . PHP_EOL . $contents;
}
file_put_contents($htpasswd, $contents);
print('<h4>output:</h4><pre>'.print_r($contents,true).'</pre>');
}catch(Exception $e) {
echo '<h3>fail: </h3>' . $e->getMessage();
}
}
if(isset($_GET['user'])){
adduser($_GET['user'], $_GET['pass']);
echo '<h3>success</h3>';
}else{
$htpasswd = '.htpasswd';
$lines = explode(PHP_EOL, file_get_contents($htpasswd)); // get .htpasswd
print('<h4>.htpasswd:</h4><pre>'.print_r(implode(PHP_EOL, $lines),true).'</pre>');
echo '<h3>no user set</h3>';
}
I would love to streamline it and clean it up.
2 Answers 2
Here is my version based on two personal preferences: I hate scroll bars and I hate repititions. Hence I prefer everything typed once and also I like my code being fully visible in the default code area on Stack Overflow.
- for this reason I removed that double spacing which just hurts my eyes
- also I removed the try catch which is a cargo cult code that makes no sense. I was never able to understand what's the point in writing a try catch that's the only job is to echo the error message when without a try catch PHP will do exactly the same - echo the error message
- also I removed the code repetitions such as mentioning the filename in a dozen places
- also I changed the algorithm, to make it add a user line only once
- I also removed that uncertainty when we can't make our mind whether we are working with an array or with a text
- I also changed the function name as it doesn't only add a user
- and some other improvements such as following the HTTP guidelines and the ability to choose the hashing algorithm without using inline comments
here it goes
<?php
$filename = ".htpasswd";
if (isset($_POST['user'])) {
manage_htpasswd($_POST['user'], $_POST['pass'], $filename);
header("Location: ".$_SERVER['REQUEST_URI']);
exit;
}
function manage_htpasswd($user, $pass, $filename, $algo = 'crypt_apr1_md5')
{
$lines = file($filename);
foreach ($lines as $i => $line) {
$existing_user = explode(':', $line);
if ($existing_user[0] === $user) {
unset($lines[$i]);
break;
}
}
if ($pass) {
$lines[] = "$user:" . $algo($pass) . PHP_EOL;
}
file_put_contents($filename, $lines);
}
?>
<form method="post">
User: <input type="text" name="user"><br>
Pass:<input type="text" name="pass"><br>
<input type="submit">
</form>
<h4><?= $filename ?>:</h4>
<pre>
<?= file_get_contents($filename) ?>
</pre>
-
\$\begingroup\$ So instead of updating a row, you delete it then append it to the end of the file. Fair enough. \$\endgroup\$mickmackusa– mickmackusa2020年11月30日 04:08:54 +00:00Commented Nov 30, 2020 at 4:08
-
1\$\begingroup\$ @mickmackusa yeah I was thinking how to get rid of that found/not found business :) \$\endgroup\$Your Common Sense– Your Common Sense2020年11月30日 04:10:38 +00:00Commented Nov 30, 2020 at 4:10
-
\$\begingroup\$ I think I like your non-regex versus my regex technique. Perhaps use
strstr()
with atrue
param instead of exploding to create$existing_user
from an array (this way you don't even need to declare the variable). Your snippet is still making iterated function calls, but it does return early -- looks pretty clean to me and built with flexibility in mind. \$\endgroup\$mickmackusa– mickmackusa2020年11月30日 04:15:33 +00:00Commented Nov 30, 2020 at 4:15 -
1\$\begingroup\$ @tony wait. indeed it does that. let me check \$\endgroup\$Your Common Sense– Your Common Sense2020年12月03日 06:31:31 +00:00Commented Dec 3, 2020 at 6:31
-
1\$\begingroup\$ @tony I beg my pardon. yes, my bad. there must be a PHP_EOL. So, when we are moving from a text to an array, every line in this array holds a new line symbol. when file_put_contents converts an array back to text, it just glues all the lines together. if a line holds a new line symbol, then the next one occurs on the new line. But once the line doesn't have a new line at the end, then the next line is glued to it. So every line should have a new line at the end. Means we have to add it to the new line. \$\endgroup\$Your Common Sense– Your Common Sense2020年12月03日 06:37:46 +00:00Commented Dec 3, 2020 at 6:37
For the sake of your code and mine, I hope that the usernames cannot contain any colons since that is the delimiting character between usernames and passwords!
I have a bias toward regex because I have a fair handle on it and I enjoy the utility and brevity that it affords my scripts. I also don't (personally) enjoy all of the imploding and exploding going on in your script.
The search pattern is the same for cases of deleting and updating -- only the replacement text is changed. My search pattern will look for an optional leading newline character/sequence with \R
, then search for an identical match of the username followed by a colon, then match the remainder of the line of text. This line-consuming pattern means that if replacing with an empty string, then there will be no blank line in the file; alternatively, if updating, then a leading EOL character/sequence will be prepended (don't worry, I ltrim()
later).
The single preg_replace()
call will record the number of replacements that it makes. The number will be either 0 or 1 since the fourth parameter limits the replacements to 1 anyhow. If there were no replacements made, then logically we know that a new line is to be appended to the end of the file.
At the end of the custom function, I am going the extra step of returning the action that was successfully undertaken. The will give better information in the output.
isset()
can receive multiple arguments, so I added the pass element as well since it is expected with the submission.
I am using printf()
to output the mix of literal and dynamic text -- I find that it helps to make the code more readable.
Untested Code:
function editHtpasswordRow(string $user, string $pass): string
{
$file = '.htpasswd';
if ($pass === '') {
$newRow = '';
$action = 'Delete';
} else {
$newRow = PHP_EOL . $user . ':' . crypt_apr1_md5($pass);
$action = 'Update';
}
$content = preg_replace(
'/\R?^' . preg_quote($user, '/') . ':.*/mu',
$newRow,
file_get_contents($file),
1,
$count
);
if (!$count && $newRow) {
$content .= PHP_EOL . $newRow;
$action = 'Insert';
}
file_put_contents($file, ltrim($content));
return $action;
}
if (isset($_POST['user'], $_POST['pass'])) {
printf(
'<h3>%s of %s was successful</h3>',
editHtpasswordRow($_POST['user'], $_POST['pass']),
htmlspecialchars($_POST['user'])
);
} else {
echo '<h4>Fetched .htpasswd content:</h4><pre>' . file_get_contents('.htpasswd') . '</pre>';
}
Edit: I missed something that YourCommonSense spotted... You should be using $_POST
when writing to the filesystem. $_GET
is for reading and $_POST
is for writing. I'll update my snippet now, +1 his post, and advise you to use his html form.