Well, there are three main parts to this:
- Getting the list of users to lock
- Locking the users
- Notifying the system administrator.
There are also a few things to watch out for... For example, you want the program to not lock the root user, or any other special-user accounts. If the program could do this, then a malicious user could lock you out of the machine just by trying to log in five times as root and getting the password wrong...
I'd probably want to separate out the three parts into separate functions, just to keep the code nice and tidy and understandable.
Shell scripts are read in a single pass, and so functions must be defined
before they are used, so you cannot do something like this:
Code:
#!/bin/bash
my_function
another_function
my_function () {
# define function here
}
another_function () {
# define function here
}
You can't do that because at the time the shell calls the functions my_function and another_function, they are not defined.
However, defining long set of functions first, and then calling them all from the bottom of the script makes it a little hard to read the script and understand it - something any good programmer should keep in mind.
To improve readability, I like to formulate long scripts like this:
Code:
#!/bin/bash
main () {
# The main flow of the program goes here
some_function
}
some_function () {
# We can define this here because we _call_ main at the end of the file after this
}
main "$@"
This is just my preference, but it might help to explain why I structure the script as I do.
So, here goes, what I came up with. The email mechanism will need to be tailored to your system, depening on what command line mail tools you have available.
Code:
#!/bin/bash
# This variable holds the number of consecutive failed attempts allowed
# before the action is taken
FAILNUM=3
# Change this to send to whatever email address you like.
NOTIFY_ADDRESS=matthew@localhost
main () {
users_to_lock=$(get_list_users_to_lock)
for u in $users_to_lock; do
lock_user $u && users_locked="$users_locked $u"
done
if [ "$users_locked" != "" ]; then
notify_admin $users_locked
fi
}
get_list_users_to_lock () {
# list the failed login attempts and successful attempts
# with reformatting to make it possible to do a chronological
# sort using the program "sort".
# we will use a temporary file to store some stuff...
tmp=$(mktemp)
# The cut command is used to print only the user name and
# the login time
lastb | cut -c1-8,40-56 | while read user attempt_date
do
if [ "$user" = "" ]; then
break
fi
# use the date command to reformat the date string
echo "$user $(date -d "$attempt_date" "+%Y%m%d%H%M%S") FAILURE"
done > "$tmp"
last | cut -c1-8,40-56 | while read user attempt_date
do
if [ "$user" = "" ]; then
break
fi
echo "$user $(date -d "$attempt_date" "+%Y%m%d%H%M%S") SUCCESS"
done >> "$tmp"
# Now we have a temp file with the username, date and status of
# each login attempt. Sorting this file will group the results
# by user and then sort by time.
# we'll use this counter to check how many failures in a row we
# have found
consecutive_failures=0
# OK, do the sort and read in the values into some variables.
sort "$tmp" |grep -v "^reboot" |while read user date result; do
case $result in
SUCCESS)
consecutive_failures=0
;;
FAILURE)
let consecutive_failures+=1
;;
esac
if [ $consecutive_failures -gt $FAILNUM ]; then
echo $user
fi
done | sort -u
# don't forget to clean up after ourselves
rm -f "$tmp"
}
lock_user () {
# get the UID (numerical ID for user)
uid=$(grep "^$1:" /etc/passwd |cut -d: -f 3)
if [ $? -ne 0 ]; then
echo "UID lookup for user $1 failed" 1>&2
return 1
fi
case "$uid" in
[0-9][0-9]*)
# we want a numerical value... don't do anything in this case
echo nop > /dev/null
;;
*)
echo "uid is not numeric - $uid - weirdness"
return 1
;;
esac
if [ "$uid" -lt 1000 ]; then
echo "Will not lock system account: $1 (uid is $uid)"
return 2
fi
# Check if the account is already locked
# the password hash is prefixed with ! if the account is locked
# the password is in the /etc/shadow file, so we can only do
# this as root.
# anyhow, if we find that the user if
grep -q "^$1:\!" /etc/shadow
case $? in
0)
# We found this line - the user is already locked
echo "account for user $1 is already locked" 1>&2
return 3
;;
1)
# OK, the account is not already locked... lets do it
usermod -L $1 && return 0 || return 4
;;
*)
# The grep failed for some reason - probably permissions on the
# /etc/shadow
return 5
;;
esac
}
notify_admin () {
cat <<EOD | mailx -s "$# account(s) locked because of failed login attempts" $NOTIFY_ADDRESS
The following user accounts have been locked because of multiple
FAILED login attempts to host $(hostname):
$*
Enjoy
--
Your lovely script
EOD
}
main "$@"
The script would presumably be run from cron once every so often.
Now, the reasons
not to use this script:
- This is not really the right approach to take at all. Your login program should be doing this, and should do it is real time. If you run the script once every 24 hours, that's a lot of time for an attacker to attack your machine without you being notified.
- There are already programs which do this sort of thing - don't re-invent the wheel.
- Calling date many times - once per line of last and lastb output is slow and loads the machine more than is necessary. A better script would parse the date internally, and thus a better choice of language would be Perl of Python, both of which have modules which can access the wtmp directly, have good date parsing functions, and thus will be a lot more efficient. This is especially important on machines which are going to have large wtmp. They can get very big - do not underestimate this.
- It is not thoroughly tested. Any program which automatically locks user accounts is dangerous, and you really shouldn't run it unless it's had serious testing.
- If I hired a programmer to write it, and got this back, I would not hire them again - wrong choice of language.
- DOS attack vector - anyone wanting to mess about another user can deliberately get their password wrong 5 times and have their account locked.
- UIDs of less than 1000 are quite OK and used for some user accounts. 1000 is commonly used on main stream Linux distros, but it is not a given - therefore you have a maintenance and portability problem.