Overview | Index by: file name | procedure name | procedure call | annotation
auth.tcl (annotations | original source)

#
#    Copyright (C) 2010 Alexandros Stergiakis <alsterg@gmail.com>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

#//#
# User and password management.
#
# Including procedures to:
#       *  Change & validate user paswords
#       *  Add & delete a user accounts
#       *  Login a user
#
#//#

namespace eval auth {
namespace import ::helper::sgets ::helper::sputs ::helper::sflush ::helper::sread
namespace export login verify passwd adduser deluser pass_is_ok name_is_ok crypt lock unlock makeroot user_exists

# Verify that supplied username and password is correct. 
#
# @param user The username string
# @param pass The password string
# @return 1 for successful verification, 0 otherwise and log reason
# @error
proc verify {user pass} {
    Global ETC_DIR
    
    if {! [name_is_ok $user] || ! [pass_is_ok $pass]} {
        log::Debug "Illegal username or password string"
        return 0
    }
    
    # Note: We don't use sed because encrypted password contains special chars
    # that cause problems to sed, and makes using sed error-prone.
    
    set fd 0
    if {[catch {
        set fd [open [file join $ETC_DIR shadow] "r"]
    } errMsg errStack]} {
        log::Critical -error -stack $errStack "Failed to open shadow file for reading"
    }

    while {[gets $fd line] >= 0} {
        set temp [split $line ":"]
        if {$user eq [lindex $temp 0]} {
            set storedpass [lindex $temp 1]
            set salt [lindex [split $storedpass \$] 2]
            set cryptpass [crypt $pass $salt]
            if {$storedpass eq $cryptpass} {
                close $fd
                return 1
            } else {
                break
            }
        }
    }
    close $fd
    
    # If we reach here, it means that username was not listed in 'shadow'
    log::Info "Password verification failed"
    
    return 0
}

# Verify that username string is syntactically correct (length, character
# composition, etc). It logs if something is wrong with the input.
#
# @param user The username string
# @return 1 if username is ok, otherwise 0 and log reason
proc name_is_ok {user} {
    Global USERNAME_MAXLEN
    
    if {[lempty $user]} {
        log::Debug "Empty username"
        return 0
    }
    
    if {[string length $user] > $USERNAME_MAXLEN} {
        log::Debug "Username too long"
        return 0
    }

    if {[regexp [format {^%s+$} {[_a-z0-9]}] $user] == 0} { ;# (@magic-number)
        log::Debug "Usersame contains invalid characters"
        return 0
    }
    return 1
}

# Verify that password string is syntactically correct (length, character
# composition, etc). It logs if something is wrong with the input.
# This procedure also makes sure that the password string does not
# start with the magic prefix $1$, which signifies within MikroConf
# an encrypted password.
#
# @param pass The password string
# @return 1 if username is ok, otherwise 0 and log reason
proc pass_is_ok {pass} {
    Global PASSWORD_MAXLEN
    
    if {[lempty $pass]} {
        log::Debug "Empty password"
        return 0
    }
    
    if {[string length $pass] > $PASSWORD_MAXLEN} {
        log::Debug "Password too long"
        return 0
    }

    if {[regexp [format {^%s+$} {[a-zA-Z0-9\.\*\?\+\[\]\(\)\^\$\|\-\\~`@#%&_=+":;\}\{<>,!]}] $pass] == 0} {
        log::Debug "Password contains invalid characters"
        return 0
    }
    
    if {[string match {$1$*} $pass]} {
        log::Debug "Password contains illegal prefix"
    }
    return 1
}

# Ask user for login credentials. Username can be provided before hand.
# Log failures, and accept up to MAX_LOGINS failed attempts before
# returning failure.
#
# @param user The username string to ask password for, if empty then \
username is also asked from the terminal
# @return 1 on successful login, 0 otherwise and reason is logged
proc login {{user {}}} {
    Global MAX_LOGINS
    
    if {! [lempty $user]} {
        if {[name_is_ok $user]} {
            set username $user
        } else { ;# error is logged
            log::Info -error "Illegal username string"
        }
    }
    
    set tries 0
    for {set tries 0} {$tries < $MAX_LOGINS} {incr tries} {
        if {[lempty $user]} {
            # Print the username prompt
            sputs -nonewline "Username: "
            sflush stdout
    
            # Read the username
            set username [sgets -noecho stdin]
        }
    
        # Print the password prompt
        sputs -nonewline "Password: "
        sflush stdout
        
        # Read the password
        set password [sgets -noecho stdin]
        
        # Verify username/password
        if {[catch {
            verify $username $password
        } result errStack]} { ;# error is logged
            return 0
        }

        if {$result} {
            log::Info "$username logged in at [clock format [clock seconds]]"
            return 1
        }
    }
    
    # Max number of tries reached
    log::Warning "Login failed for user \"$username\" after $MAX_LOGINS login attempts"
    
    # Emitted after a user fails to log-in consecutively for MAX_LOGINS number of times.
    # Useful to lock the user account after some failed logins.
    #
    # @param The username.
    # @param The number of failed logins in this session.
    event generate AUTHENTICATION MAX_LOGIN_ATTEMPTS [list $username $MAX_LOGINS]
    
    return 0
}

# Compute the MD5 checksum of a string. The resultant form is appropriate for the shadow
# file: $1$8ch_salt$24ch_encrypted_pass
#
# @param pass The password cleartext string
# @param salt A string to use as a salt as per 'cryptpw'. If not specified 'cryptpw' will select one randomly.
# @return The encrypted and appropriate formated password
# @error
proc crypt {pass {salt {}}} {
    if {! [pass_is_ok $pass]} {
        log::Info -error "Illegal password string"
    }
    if {[catch {
        if {[lempty $salt]} {
            exec cryptpw -m md5 $pass
        } else {
            exec cryptpw -S $salt -m md5 $pass
        }
        
    } result errStack]} {
        log::Error -error -stack $errStack "Failed to encrypt password string: " $result
    }
    return $result
}

# Check if a username is used in the system
#
# @param user The username string
# @return '1' if username exist, otherwise '0 and log (including error conditions)
proc user_exists {user} {
    Global ETC_DIR

    if {! [name_is_ok $user]} {
        log::Info "Usersame \"$user\" contains invalid characters"
        return 0
    }
    
    set matches [::fileutil::grep [string map "user $user" {^user:.*$}] [file join $ETC_DIR passwd]]
    if {[lempty $matches]} {
        return 0
    }
    
    return 1
}

# Change user password, without asking for the old one.
# No need to unlock a locked account.
#
# @assume We use shadow passwords
#
# @param user The username string.
# @param newpass The new password string (cleartext) to change to.
# @param nocrypt When set to something then password is assumed to be already encrypted.
# @return The MD5 checksum of the new password.
# @error
proc passwd {user newpass {nocrypt {}}} {
    Global ETC_DIR

    if {! [user_exists $user]} {
        log::Warning -error "user account \"$user\" does not exist"
    }

    if {[lempty $nocrypt]} {
        if {! [pass_is_ok $newpass]} {
            log::Info -error "illegal password string"
        }
        
        set cryptpass [crypt $newpass]
    } else {
        set cryptpass $newpass
    }
    
    # Substitute old with new pass.
    # Note: We don't use sed because encrypted password contains special chars
    # that cause problems to sed, and makes using sed error-prone.
    
    set fd 0
    if {[catch {
        set fd [open [file join $ETC_DIR shadow] "r"]
    } errMsg errStack]} {
        log::Critical -error -stack $errStack "Failed to open shadow file for reading"
    }

    # To store the modified file in memory
    set newlines [list]

    while {[gets $fd line] >= 0} {
        set temp [split $line ":"]
        if {$user eq [lindex $temp 0]} {
            lappend newlines [join [lreplace $temp 1 1 $cryptpass] ":"]
        } else {
            lappend newlines $line
        }
    }
    catch {close $fd}

    # Now write the in-memory modified file back to disk
    if {[catch {
        set fd [open [file join $ETC_DIR shadow] "w"]
    } errMsg errStack]} {
        log::Critical -error -stack $errStack "Failed to open shadow file for writing: " $errMsg
    }

    for {set i 0} {$i < [llength $newlines]} {incr i} {
        puts $fd [lindex $newlines $i]
    }
    catch {close $fd}

    return $cryptpass ;# Success
}

# Give root privileges to a user account.
#
# @assume The requested user belongs to a group with same name as the username.
#
# @param user The username string.
# @error
proc makeroot {user} {
    Global ETC_DIR

    if {! [user_exists $user]} {
        log::Warning -error "user account \"$user\" does not exist"
    }

    if {[catch {
        exec sed -i -r [string map "user $user" {s/^(user:[^:]*):[^:]*:[^:]*:(.*)$/\1:0:0:\2/g}] [file join $ETC_DIR passwd]
    } errMsg errStack]} {
        log::Error -error -stack $errStack "Failed to give user \"$user\" root privileges: " $result
    }

    if {[catch {
        exec sed -i -r [string map "user $user" {s/^(user:[^:]*):[^:]*:(.*)$/\1:0:\2/g}] [file join $ETC_DIR group]
    } errMsg errStack]} {
        log::Error -error -stack $errStack "Failed to give group \"$user\" root privileges: " $result
    }
}

# Lock a user account: Cannot be used to login.
# Ignore if user account already locked.
#
# @depend on 'passwd' output.
# @param user User name.
# @error
proc lock {user} {
    if {! [user_exists $user]} {
        log::Warning -error "User does not exist"
    }
    if {[islocked $user]} { return }
    if {[catch {
        exec passwd -l $user
    } result options]} {
        if {$result ne "passwd: password for $user is already locked"} {
            return -options $options $result
        }
    }
}

# Unlock a user account: Can be used to login.
# Ignore if user account already unlocked.
#
# @depend on 'passwd' output.
# @param user User name.
# @error
proc unlock {user} {
    if {! [user_exists $user]} {
        log::Warning -error "User does not exist"
    }
    if {! [islocked $user]} { return }
    if {[catch {
        exec passwd -u $user
    } result options]} {
        if {$result ne "passwd: password for $user is already unlocked"} {
            return -options $options $result
        }
    }
}

# Find if a user account is locked/unlocked.
#
# @param user User name.
# @return true if locked, false if unlocked or non-existent.
proc islocked {user} {
    Global ETC_DIR
    set matches [::fileutil::grep [string map "user $user" {^user:!:.*$}] [file join $ETC_DIR shadow]]
    if {[lempty $matches]} {
        return 0
    }
    return 1
}

# Create a new user account
#
# @param user The username string.
# @param shell The user shell. MikroConf shell is used if not provided.
# @param pass The cleartext password string for the new user. Login is dissabled if not provided.
# @return The MD5 checksum of the new password, or nothing if no password was provided.
# @error
proc adduser {user shell {pass {}}} {
    Global HOME_DIR
    
    if {! [name_is_ok $user]} {
        log::Notice -error "illegal username"
    }
    
    if {[user_exists $user]} {
        log::Notice -error "A user with same name already exists"
    }
    
    if {[catch {
        exec adduser -h $HOME_DIR -g $user -s $shell -D -H $user
    } errMsg errStack]} {
        log::Error -error -stack $errStack "Failed to add user \"$user\": " $errMsg
    }
    
    if {! [lempty $pass]} {
        return [passwd $user $pass]
    }
    
    return ;# Success
}

# Delete a user account from the system
#
# @param user The username string
# @return nothing for success
# @error
proc deluser {user} {
    if {! [user_exists $user]} {
        log::Warning -error "Cannot remove user \"$user\". User does not exist"
    }
    
    if {[catch {
            exec deluser $user
    } errMsg errStack]} {
        log::Error -error -stack $errStack "Failed to delete user \"$user\": " $errMsg
    }
    return ;# Success
}

namespace ensemble create

} ;# namespace ends

Overview | Index by: file name | procedure name | procedure call | annotation
File generated 2010-03-13 at 22:28.