1# (c) 2007 Chris AtLee <chris@atlee.ca>
2# Licensed under the MIT license:
3# http://www.opensource.org/licenses/mit-license.php
4#
5# Original author: Chris AtLee
6#
7# Modified by David Ford, 2011-12-6
8# added py3 support and encoding
9# added pam_end
10# added pam_setcred to reset credentials after seeing Leon Walker's remarks
11# added byref as well
12# use readline to prestuff the getuser input
13
14'''
15PAM module for python
16
17Provides an authenticate function that will allow the caller to authenticate
18a user against the Pluggable Authentication Modules (PAM) on the system.
19
20Implemented using ctypes, so no compilation is necessary.
21'''
22
23__all__      = ['pam']
24__version__  = '1.8.4'
25__author__   = 'David Ford <david@blue-labs.org>'
26__released__ = '2018 June 15'
27
28import sys
29
30from ctypes import CDLL, POINTER, Structure, CFUNCTYPE, cast, byref, sizeof
31from ctypes import c_void_p, c_size_t, c_char_p, c_char, c_int
32from ctypes import memmove
33from ctypes.util import find_library
34
35class PamHandle(Structure):
36    """wrapper class for pam_handle_t pointer"""
37    _fields_ = [ ("handle", c_void_p) ]
38
39    def __init__(self):
40        Structure.__init__(self)
41        self.handle = 0
42
43class PamMessage(Structure):
44    """wrapper class for pam_message structure"""
45    _fields_ = [ ("msg_style", c_int), ("msg", c_char_p) ]
46
47    def __repr__(self):
48        return "<PamMessage %i '%s'>" % (self.msg_style, self.msg)
49
50class PamResponse(Structure):
51    """wrapper class for pam_response structure"""
52    _fields_ = [ ("resp", c_char_p), ("resp_retcode", c_int) ]
53
54    def __repr__(self):
55        return "<PamResponse %i '%s'>" % (self.resp_retcode, self.resp)
56
57conv_func = CFUNCTYPE(c_int, c_int, POINTER(POINTER(PamMessage)), POINTER(POINTER(PamResponse)), c_void_p)
58
59class PamConv(Structure):
60    """wrapper class for pam_conv structure"""
61    _fields_ = [ ("conv", conv_func), ("appdata_ptr", c_void_p) ]
62
63# Various constants
64PAM_PROMPT_ECHO_OFF       = 1
65PAM_PROMPT_ECHO_ON        = 2
66PAM_ERROR_MSG             = 3
67PAM_TEXT_INFO             = 4
68PAM_REINITIALIZE_CRED     = 8
69
70libc                      = CDLL(find_library("c"))
71libpam                    = CDLL(find_library("pam"))
72
73calloc                    = libc.calloc
74calloc.restype            = c_void_p
75calloc.argtypes           = [c_size_t, c_size_t]
76
77# bug #6 (@NIPE-SYSTEMS), some libpam versions don't include this function
78if hasattr(libpam, 'pam_end'):
79    pam_end                   = libpam.pam_end
80    pam_end.restype           = c_int
81    pam_end.argtypes          = [PamHandle, c_int]
82
83pam_start                 = libpam.pam_start
84pam_start.restype         = c_int
85pam_start.argtypes        = [c_char_p, c_char_p, POINTER(PamConv), POINTER(PamHandle)]
86
87pam_setcred               = libpam.pam_setcred
88pam_setcred.restype       = c_int
89pam_setcred.argtypes      = [PamHandle, c_int]
90
91pam_strerror              = libpam.pam_strerror
92pam_strerror.restype      = c_char_p
93pam_strerror.argtypes     = [PamHandle, c_int]
94
95pam_authenticate          = libpam.pam_authenticate
96pam_authenticate.restype  = c_int
97pam_authenticate.argtypes = [PamHandle, c_int]
98
99class pam():
100    code   = 0
101    reason = None
102
103    def __init__(self):
104        pass
105
106    def authenticate(self, username, password, service='login', encoding='utf-8', resetcreds=True):
107        """username and password authentication for the given service.
108
109           Returns True for success, or False for failure.
110
111           self.code (integer) and self.reason (string) are always stored and may
112           be referenced for the reason why authentication failed. 0/'Success' will
113           be stored for success.
114
115           Python3 expects bytes() for ctypes inputs.  This function will make
116           necessary conversions using the supplied encoding.
117
118        Inputs:
119          username: username to authenticate
120          password: password in plain text
121          service:  PAM service to authenticate against, defaults to 'login'
122
123        Returns:
124          success:  True
125          failure:  False
126        """
127
128        @conv_func
129        def my_conv(n_messages, messages, p_response, app_data):
130            """Simple conversation function that responds to any
131               prompt where the echo is off with the supplied password"""
132            # Create an array of n_messages response objects
133            addr = calloc(n_messages, sizeof(PamResponse))
134            response = cast(addr, POINTER(PamResponse))
135            p_response[0] = response
136            for i in range(n_messages):
137                if messages[i].contents.msg_style == PAM_PROMPT_ECHO_OFF:
138                    dst = calloc(len(password)+1, sizeof(c_char))
139                    memmove(dst, cpassword, len(password))
140                    response[i].resp = dst
141                    response[i].resp_retcode = 0
142            return 0
143
144        # python3 ctypes prefers bytes
145        if sys.version_info >= (3,):
146            if isinstance(username, str): username = username.encode(encoding)
147            if isinstance(password, str): password = password.encode(encoding)
148            if isinstance(service, str):  service  = service.encode(encoding)
149        else:
150            if isinstance(username, unicode):
151                username = username.encode(encoding)
152            if isinstance(password, unicode):
153                password = password.encode(encoding)
154            if isinstance(service, unicode):
155                service  = service.encode(encoding)
156
157        if b'\x00' in username or b'\x00' in password or b'\x00' in service:
158            self.code = 4  # PAM_SYSTEM_ERR in Linux-PAM
159            self.reason = 'strings may not contain NUL'
160            return False
161
162        # do this up front so we can safely throw an exception if there's
163        # anything wrong with it
164        cpassword = c_char_p(password)
165
166        handle = PamHandle()
167        conv   = PamConv(my_conv, 0)
168        retval = pam_start(service, username, byref(conv), byref(handle))
169
170        if retval != 0:
171            # This is not an authentication error, something has gone wrong starting up PAM
172            self.code   = retval
173            self.reason = "pam_start() failed"
174            return False
175
176        retval = pam_authenticate(handle, 0)
177        auth_success = retval == 0
178
179        if auth_success and resetcreds:
180            retval = pam_setcred(handle, PAM_REINITIALIZE_CRED);
181
182        # store information to inform the caller why we failed
183        self.code   = retval
184        self.reason = pam_strerror(handle, retval)
185        if sys.version_info >= (3,):
186            self.reason = self.reason.decode(encoding)
187
188        if hasattr(libpam, 'pam_end'):
189            pam_end(handle, retval)
190
191        return auth_success
192
193
194def authenticate(*vargs, **dargs):
195    """
196    Compatibility function for older versions of python-pam.
197    """
198    return pam().authenticate(*vargs, **dargs)
199
200
201if __name__ == "__main__":
202    import readline, getpass
203
204    def input_with_prefill(prompt, text):
205        def hook():
206            readline.insert_text(text)
207            readline.redisplay()
208        readline.set_pre_input_hook(hook)
209
210        if sys.version_info >= (3,):
211            result = input(prompt)
212        else:
213            result = raw_input(prompt)
214
215        readline.set_pre_input_hook()
216        return result
217
218    pam = pam()
219
220    username = input_with_prefill('Username: ', getpass.getuser())
221
222    # enter a valid username and an invalid/valid password, to verify both failure and success
223    pam.authenticate(username, getpass.getpass())
224    print('{} {}'.format(pam.code, pam.reason))
225