1
2# pam.py - functions authentication, authorisation and session handling
3#
4# Copyright (C) 2010-2019 Arthur de Jong
5#
6# This library is free software; you can redistribute it and/or
7# modify it under the terms of the GNU Lesser General Public
8# License as published by the Free Software Foundation; either
9# version 2.1 of the License, or (at your option) any later version.
10#
11# This library is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14# Lesser General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public
17# License along with this library; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19# 02110-1301 USA
20
21import logging
22import random
23import socket
24import time
25
26import ldap
27from ldap.controls.ppolicy import PasswordPolicyControl, PasswordPolicyError
28from ldap.filter import escape_filter_chars
29
30import cfg
31import common
32import constants
33import passwd
34import search
35import shadow
36
37
38random = random.SystemRandom()
39
40
41def authenticate(binddn, password):
42    # open a new connection
43    conn = search.Connection()
44    # bind using the specified credentials
45    pwctrl = PasswordPolicyControl()
46    res, data, msgid, ctrls = conn.simple_bind_s(binddn, password, serverctrls=[pwctrl])
47    # go over bind result server controls
48    for ctrl in ctrls:
49        if ctrl.controlType == PasswordPolicyControl.controlType:
50            # found a password policy control
51            logging.debug(
52                'PasswordPolicyControl found: error=%s (%s), '
53                'timeBeforeExpiration=%s, graceAuthNsRemaining=%s',
54                'None' if ctrl.error is None else PasswordPolicyError(ctrl.error).prettyPrint(),
55                ctrl.error, ctrl.timeBeforeExpiration, ctrl.graceAuthNsRemaining)
56            if ctrl.error == 0:  # passwordExpired
57                return (
58                    conn, constants.NSLCD_PAM_AUTHTOK_EXPIRED,
59                    PasswordPolicyError(ctrl.error).prettyPrint())
60            elif ctrl.error == 1:  # accountLocked
61                return (
62                    conn, constants.NSLCD_PAM_ACCT_EXPIRED,
63                    PasswordPolicyError(ctrl.error).prettyPrint())
64            elif ctrl.error == 2:  # changeAfterReset
65                return (
66                    conn, constants.NSLCD_PAM_NEW_AUTHTOK_REQD,
67                    'Password change is needed after reset')
68            elif ctrl.error:
69                return (
70                    conn, constants.NSLCD_PAM_PERM_DENIED,
71                    PasswordPolicyError(ctrl.error).prettyPrint())
72            elif ctrl.timeBeforeExpiration is not None:
73                return (
74                    conn, constants.NSLCD_PAM_NEW_AUTHTOK_REQD,
75                    'Password will expire in %d seconds' % ctrl.timeBeforeExpiration)
76            elif ctrl.graceAuthNsRemaining is not None:
77                return (
78                    conn, constants.NSLCD_PAM_NEW_AUTHTOK_REQD,
79                    'Password expired, %d grace logins left' % ctrl.graceAuthNsRemaining)
80    # perform search for own object (just to do any kind of search)
81    results = search.LDAPSearch(
82        conn, base=binddn, scope=ldap.SCOPE_BASE,
83        filter='(objectClass=*)', attributes=['dn'])
84    for entry in results:
85        if entry[0] == binddn:
86            return conn, constants.NSLCD_PAM_SUCCESS, ''
87    # if our DN wasn't found raise an error to signal bind failure
88    raise ldap.NO_SUCH_OBJECT()
89
90
91def pwmod(conn, userdn, oldpassword, newpassword):
92    # perform request without old password
93    try:
94        conn.passwd_s(userdn, None, newpassword)
95    except ldap.LDAPError:
96        # retry with old password
97        if oldpassword:
98            conn.passwd_s(userdn, oldpassword, newpassword)
99        else:
100            raise
101
102
103def update_lastchange(conns, userdn):
104    """Try to update the shadowLastChange attribute of the entry."""
105    attribute = shadow.attmap['shadowLastChange']
106    if str(attribute) == '"${shadowLastChange:--1}"':
107        attribute = 'shadowLastChange'
108    if not attribute or '$' in str(attribute):
109        raise ValueError('shadowLastChange has unsupported mapping')
110    # build the value for the new attribute
111    if attribute.lower() == 'pwdlastset':
112        # for AD we use another timestamp */
113        value = '%d000000000' % (int(time.time()) // 100 + (134774 * 864))
114    else:
115        # time in days since Jan 1, 1970
116        value = '%d' % (int(time.time()) // (60 * 60 * 24))
117    # perform the modification, return at first success
118    for conn in conns:
119        try:
120            conn.modify_s(userdn, [(ldap.MOD_REPLACE, attribute, [value.encode('utf-8')])])
121            return
122        except ldap.LDAPError:
123            pass  # ignore error and try next connection
124
125
126class PAMRequest(common.Request):
127
128    def validate(self, parameters):
129        """Check the username for validity and fill in the DN if needed."""
130        # check username for validity
131        common.validate_name(parameters['username'])
132        # look up user DN
133        entry = passwd.uid2entry(self.conn, parameters['username'])
134        if not entry:
135            # FIXME: we should close the stream with an empty response here
136            raise ValueError('%r: user not found' % parameters['username'])
137        # save the DN
138        parameters['userdn'] = entry[0]
139        # get the "real" username
140        value = passwd.attmap.get_rdn_value(entry[0], 'uid')
141        if not value:
142            # get the username from the uid attribute
143            values = entry[1]['uid']
144            if not values or not values[0]:
145                logging.warning('%s: is missing a %s attribute', entry[0], passwd.attmap['uid'])
146            value = values[0]
147        # check the username
148        if value and not common.is_valid_name(value):
149            raise ValueError('%s: has invalid %s attribute', entry[0], passwd.attmap['uid'])
150        # check if the username is different and update it if needed
151        if value != parameters['username']:
152            logging.info('username changed from %r to %r', parameters['username'], value)
153            parameters['username'] = value
154
155
156class PAMAuthenticationRequest(PAMRequest):
157
158    action = constants.NSLCD_ACTION_PAM_AUTHC
159
160    def read_parameters(self, fp):
161        return dict(username=fp.read_string(),
162                    service=fp.read_string(),
163                    ruser=fp.read_string(),
164                    rhost=fp.read_string(),
165                    tty=fp.read_string(),
166                    password=fp.read_string())
167        # TODO: log call with parameters
168
169    def write(self, username, authc=constants.NSLCD_PAM_SUCCESS,
170              authz=constants.NSLCD_PAM_SUCCESS, msg=''):
171        self.fp.write_int32(constants.NSLCD_RESULT_BEGIN)
172        self.fp.write_int32(authc)
173        self.fp.write_string(username)
174        self.fp.write_int32(authz)
175        self.fp.write_string(msg)
176        self.fp.write_int32(constants.NSLCD_RESULT_END)
177
178    def handle_request(self, parameters):
179        # if the username is blank and rootpwmoddn is configured, try to
180        # authenticate as administrator, otherwise validate request as usual
181        if not parameters['username'] and cfg.rootpwmoddn:
182            # authenticate as rootpwmoddn
183            binddn = cfg.rootpwmoddn
184            # if the caller is root we will allow the use of rootpwmodpw
185            if not parameters['password'] and self.calleruid == 0 and cfg.rootpwmodpw:
186                password = cfg.rootpwmodpw
187            elif parameters['password']:
188                password = parameters['password']
189            else:
190                raise ValueError('password missing')
191        else:
192            self.validate(parameters)
193            binddn = parameters['userdn']
194            password = parameters['password']
195        # try authentication
196        try:
197            conn, authz, msg = authenticate(binddn, password)
198        except ldap.INVALID_CREDENTIALS as e:
199            try:
200                msg = e[0]['desc']
201            except Exception:
202                msg = str(e)
203            logging.debug('bind failed: %s', msg)
204            self.write(parameters['username'], authc=constants.NSLCD_PAM_AUTH_ERR, msg=msg)
205            return
206        if authz != constants.NSLCD_PAM_SUCCESS:
207            logging.warning('%s: %s: %s', binddn, parameters['username'], msg)
208        else:
209            logging.debug('bind successful')
210        # FIXME: perform shadow attribute checks with check_shadow()
211        self.write(parameters['username'], authz=authz, msg=msg)
212
213
214class PAMAuthorisationRequest(PAMRequest):
215
216    action = constants.NSLCD_ACTION_PAM_AUTHZ
217
218    def read_parameters(self, fp):
219        return dict(username=fp.read_string(),
220                    service=fp.read_string(),
221                    ruser=fp.read_string(),
222                    rhost=fp.read_string(),
223                    tty=fp.read_string())
224        # TODO: log call with parameters
225
226    def write(self, authz=constants.NSLCD_PAM_SUCCESS, msg=''):
227        self.fp.write_int32(constants.NSLCD_RESULT_BEGIN)
228        self.fp.write_int32(authz)
229        self.fp.write_string(msg)
230        self.fp.write_int32(constants.NSLCD_RESULT_END)
231
232    def check_authz_search(self, parameters):
233        if not cfg.pam_authz_searches:
234            return
235        # escape all parameters
236        variables = dict((k, escape_filter_chars(v)) for k, v in parameters.items())
237        variables.update(
238            hostname=escape_filter_chars(socket.gethostname()),
239            fqdn=escape_filter_chars(socket.getfqdn()),
240            dn=variables['userdn'],
241            uid=variables['username'])
242        # go over all authz searches
243        for x in cfg.pam_authz_searches:
244            filter = x.value(variables)
245            logging.debug('trying pam_authz_search "%s"', filter)
246            srch = search.LDAPSearch(self.conn, filter=filter, attributes=('dn', ))
247            try:
248                dn, values = srch.items().next()
249            except StopIteration:
250                logging.error('pam_authz_search "%s" found no matches', filter)
251                raise
252            logging.debug('pam_authz_search found "%s"', dn)
253
254    def handle_request(self, parameters):
255        # fill in any missing userdn, etc.
256        self.validate(parameters)
257        # check authorisation search
258        try:
259            self.check_authz_search(parameters)
260        except StopIteration:
261            self.write(constants.NSLCD_PAM_PERM_DENIED,
262                       'LDAP authorisation check failed')
263            return
264        # all tests passed, return OK response
265        self.write()
266
267
268class PAMPasswordModificationRequest(PAMRequest):
269
270    action = constants.NSLCD_ACTION_PAM_PWMOD
271
272    def read_parameters(self, fp):
273        return dict(username=fp.read_string(),
274                    service=fp.read_string(),
275                    ruser=fp.read_string(),
276                    rhost=fp.read_string(),
277                    tty=fp.read_string(),
278                    asroot=fp.read_int32(),
279                    oldpassword=fp.read_string(),
280                    newpassword=fp.read_string())
281        # TODO: log call with parameters
282
283    def write(self, rc=constants.NSLCD_PAM_SUCCESS, msg=''):
284        self.fp.write_int32(constants.NSLCD_RESULT_BEGIN)
285        self.fp.write_int32(rc)
286        self.fp.write_string(msg)
287        self.fp.write_int32(constants.NSLCD_RESULT_END)
288
289    def handle_request(self, parameters):
290        # fill in any missing userdn, etc.
291        self.validate(parameters)
292        # check if pam_password_prohibit_message is set
293        if cfg.pam_password_prohibit_message:
294            self.write(constants.NSLCD_PAM_PERM_DENIED,
295                       cfg.pam_password_prohibit_message)
296            return
297        # check if the the user passed the rootpwmoddn
298        if parameters['asroot']:
299            binddn = cfg.rootpwmoddn
300            # check if rootpwmodpw should be used
301            if not parameters['oldpassword'] and self.calleruid == 0 and cfg.rootpwmodpw:
302                password = cfg.rootpwmodpw
303            elif parameters['oldpassword']:
304                password = parameters['oldpassword']
305            else:
306                raise ValueError('password missing')
307        else:
308            binddn = parameters['userdn']
309            password = parameters['oldpassword']
310            # TODO: check if shadow properties allow password change
311        # perform password modification
312        try:
313            conn, authz, msg = authenticate(binddn, password)
314            pwmod(conn, parameters['userdn'], parameters['oldpassword'], parameters['newpassword'])
315            # try to update lastchange with normal or user connection
316            update_lastchange((self.conn, conn), parameters['userdn'])
317        except ldap.INVALID_CREDENTIALS as e:
318            try:
319                msg = e[0]['desc']
320            except Exception:
321                msg = str(e)
322            logging.debug('pwmod failed: %s', msg)
323            self.write(constants.NSLCD_PAM_PERM_DENIED, msg)
324            return
325        logging.debug('pwmod successful')
326        self.write()
327
328
329SESSION_ID_LENGTH = 25
330SESSION_ID_ALPHABET = (
331    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
332    "abcdefghijklmnopqrstuvwxyz" +
333    "01234567890"
334)
335
336
337def generate_session_id():
338    return ''.join(
339        random.choice(SESSION_ID_ALPHABET)
340        for i in range(SESSION_ID_LENGTH)
341    )
342
343
344class PAMSessionOpenRequest(PAMRequest):
345
346    action = constants.NSLCD_ACTION_PAM_SESS_O
347
348    def read_parameters(self, fp):
349        return dict(username=fp.read_string(),
350                    service=fp.read_string(),
351                    ruser=fp.read_string(),
352                    rhost=fp.read_string(),
353                    tty=fp.read_string())
354        # TODO: log call with parameters
355
356    def write(self, sessionid):
357        self.fp.write_int32(constants.NSLCD_RESULT_BEGIN)
358        self.fp.write_string(sessionid)
359        self.fp.write_int32(constants.NSLCD_RESULT_END)
360
361    def handle_request(self, parameters):
362        # generate a session id
363        session_id = generate_session_id()
364        self.write(session_id)
365
366
367class PAMSessionCloseRequest(PAMRequest):
368
369    action = constants.NSLCD_ACTION_PAM_SESS_C
370
371    def read_parameters(self, fp):
372        return dict(username=fp.read_string(),
373                    service=fp.read_string(),
374                    ruser=fp.read_string(),
375                    rhost=fp.read_string(),
376                    tty=fp.read_string(),
377                    session_id=fp.read_string())
378        # TODO: log call with parameters
379
380    def write(self):
381        self.fp.write_int32(constants.NSLCD_RESULT_BEGIN)
382        self.fp.write_int32(constants.NSLCD_RESULT_END)
383
384    def handle_request(self, parameters):
385        self.write()
386