#!/usr/bin/python3
# -*- mode: python -*-
# This script is an interactive way to manipulate fields in the LDAP directory.
# When run it connects to the directory using the current users ID and fetches
# all the attributes for that user. It then formats them nicely and allows
# the user to change them.
# It is possible to authenticate as someone differnt than you are viewing/changing
# this allows administrative functions and also allows users to view
# restricted information about others, such as phone numbers and addresses.
#
#  Usage: userinfo -a <user> -u <user> -c <user> -r
#    -a    Set the authentication user (the user whose password you are
#          going to enter)
#    -u    Set the user to display
#    -c    Set both -a and -u, use this if your login uid is not in the
#          database
#    -r    Enable 'root' functions, do this if your uid has access to
#          restricted variables.

#   Copyright (c) 1999-2001  Jason Gunthorpe <jgg@debian.org>
#   Copyright (c) 2004-2005,7,8  Joey Schulze <joey@infodrom.org>
#   Copyright (c) 2001-2006  Ryan Murray <rmurray@debian.org>
#   Copyright (c) 2008,2009 Peter Palfrader <peter@palfrader.org>
#   Copyright (c) 2008 Martin Zobel-Helas <zobel@debian.org>
#   Copyright (c) 2008 Marc 'HE' Brockschmidt <he@debian.org>
#   Copyright (c) 2008 Mark Hymers <mhy@debian.org>
#   Copyright (c) 2008 Thomas Viehmann <tv@beamnet.de>
#
#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 2 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 General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.

from __future__ import print_function

import copy
import getopt
import getpass
import os
import pwd
import re
import sys
import time

import ldap
from six.moves import input

from userdir_ldap.ldap import (
    GetAttr, GenPass, HashPass, BaseDn, FormatPGPKey, FormatSSHAuth,
    EmailAddress, connectLDAP)


RootMode = 0
AttrInfo = {"cn": ["First Name", 101],
            "mn": ["Middle Name", 102],
            "sn": ["Surname", 103],
            "c": ["Country Code", 1],
            "l": ["Locality", 2],
            "ou": ["Membership", 0],
            "facsimileTelephoneNumber": ["Fax Phone Number", 3],
            "telephoneNumber": ["Phone Number", 4],
            "postalAddress": ["Mailing Address", 5],
            "postalCode": ["Postal Code", 6],
            "uid": ["Unix User ID", 0],
            "loginShell": ["Unix Shell", 7],
            "supplementaryGid": ["Unix Groups", 0],
            "allowedHost": ["Host ACL", 0],
            "member": ["LDAP Group", 0],
            "emailForward": ["Email Forwarding", 8],
            "ircNick": ["IRC Nickname", 9],
            "onVacation": ["Vacation Message", 10],
            "labeledURI": ["Home Page", 11],
            "latitude": ["Latitude", 12],
            "longitude": ["Longitude", 13],
            "icqUin": ["ICQ UIN", 14],
            "jabberJID": ["Jabber ID", 15],
            "privateSub": ["Debian-Private", 16],
            "birthDate": ["Date of Birth", 18],
            "mailDisableMessage": ["Mail Disabled", 19],
            "mailGreylisting": ["Mail Greylisting", 20],
            "mailCallout": ["Mail Callouts", 21],
            "mailRBL": ["Mail RBLs", 22],
            "mailRHSBL": ["Mail RHSBLs", 23],
            "mailWhitelist": ["Mail Whitelist", 24],
            "mailContentInspectionAction": ["mail C-I Action", 25],
            "VoIP": ["VoIP Address", 26],
            "comment": ["Comment", 116],
            "userPassword": ["Crypted Password", 117],
            "dnsZoneEntry": ["d.net Entry", 118],
            "accountStatus": ["DD status", 301],
            "accountComment": ["DD status comment", 302],
            }

AttrPrompt = {"cn": ["Common name or first name"],
              "mn": ["Middle name (or initial if it ends in a dot)"],
              "sn": ["Surname or last name"],
              "c": ["ISO 2 letter country code, such as US, DE, etc"],
              "l": ["City name, State/Provice (Locality)\n e.g. Dallas, Texas"],
              "facsimileTelephoneNumber": ["Fax phone number, with area code and country code"],
              "telephoneNumber": ["Voice phone number"],
              "postalAddress": ["Complete mailing address including postal codes and country designations\nSeperate lines using a $ character"],
              "postalCode": ["Postal Code or Zip Code"],
              "loginShell": ["Login shell with full path (no check is done for validity)"],
              "emailForward": ["EMail address to send all mail to or blank to disable"],
              "ircNick": ["IRC nickname if you use IRC"],
              "onVacation": ["A message if on vaction, indicating the time of departure and return"],
              "userPassword": ["The users Crypt'd password"],
              "comment": ["Admin Comment about the account"],
              "supplementaryGid": ["Groups the user is in"],
              "allowedHost": ["Grant access to certain hosts"],
              "privateSub": ["Debian-Private mailing list subscription"],
              "birthDate": ["Date of Birth (YYYYMMDD)"],
              "mailDisableMessage": ["Error message to return via SMTP"],
              "mailGreylisting": ["SMTP Greylisting (TRUE/FALSE)"],
              "mailCallout": ["SMTP Callouts (TRUE/FALSE)"],
              "mailRBL": ["SMTP time RBL lists"],
              "mailRHSBL": ["SMTP time RHSBL lists"],
              "mailWhitelist": ["SMTP time whitelist from other checks"],
              "mailContentInspectionAction": ["Content Inspection Action (reject, blackhole, markup)"],
              "member": ["LDAP Group Member for slapd ACLs"],
              "latitude": ["XEarth latitude in ISO 6709 format - see /usr/share/zoneinfo/zone.tab or etak.com"],
              "longitude": ["XEarth latitude in ISO 6709 format - see /usr/share/zoneinfo/zone.tab or etak.com"],
              "dnsZoneEntry": ["DNS Zone fragment associated this this user"],
              "labeledURI": ["Web home page"],
              "jabberJID": ["Jabber ID"],
              "icqUin": ["ICQ UIN Number"],
              "VoIP": ["VoIP Address"],
              "accountStatus": ["DD status"],
              "accountComment": ["DD status comment"],
              }

# Create a map of IDs to desc,value,attr
OrderedIndex = {}
for at in AttrInfo.keys():
   if (AttrInfo[at][1] != 0):
      OrderedIndex[AttrInfo[at][1]] = [AttrInfo[at][0], "", at]
OrigOrderedIndex = copy.deepcopy(OrderedIndex)

for id in OrderedIndex:
   if OrderedIndex[id][2] not in AttrPrompt:
      print("Warning: no AttrPrompt for %s" % id)


# Show shadow information
def PrintShadow(Attrs):
   Changed = int(GetAttr(Attrs, "shadowLastChange", "0"))
   MaxDays = int(GetAttr(Attrs, "shadowMax", "0"))
   InactDays = int(GetAttr(Attrs, "shadowInactive", "0"))
   Expire = int(GetAttr(Attrs, "shadowExpire", "0"))

   print("%-24s:" % ("Password last changed"), end=" ")
   print(time.strftime("%a %d/%m/%Y %Z", time.localtime(Changed * 24 * 60 * 60)))
   if (Expire > 0):
      print("%-24s:" % ("Account expires on"), end=" ")
      print(time.strftime("%a %d/%m/%Y %Z", time.localtime(Expire * 24 * 60 * 60)))
   if (InactDays >= 0 and MaxDays < 99999):
      print("Account aging is active, you must change your password every", MaxDays, "days.")


# Print out the automatic time stamp information
def PrintModTime(Attrs):
   Stamp = GetAttr(Attrs, "modifyTimestamp", "")
   if len(Stamp) >= 13:
      Time = (int(Stamp[0:4]), int(Stamp[4:6]), int(Stamp[6:8]),
              int(Stamp[8:10]), int(Stamp[10:12]), int(Stamp[12:14]), 0, 0, -1)
      print("%-24s:" % ("Record last modified on"), time.strftime("%a %d/%m/%Y %X UTC", Time), end=" ")
      print("by", ldap.explode_dn(GetAttr(Attrs, "modifiersName"), 1)[0])

   Stamp = GetAttr(Attrs, "createTimestamp", "")
   if len(Stamp) >= 13:
      Time = (int(Stamp[0:4]), int(Stamp[4:6]), int(Stamp[6:8]),
              int(Stamp[8:10]), int(Stamp[10:12]), int(Stamp[12:14]), 0, 0, -1)
      print("%-24s:" % ("Record created on"), time.strftime("%a %d/%m/%Y %X UTC", Time))


# Print the PGP key for a user
def PrintKeys(Attrs):
   if "keyFingerPrint" not in Attrs[1]:
      return
   First = 0
   for x in Attrs[1]["keyFingerPrint"]:
      if First == 0:
         print("%-24s:" % "PGP/GPG Key Fingerprints", end=" ")
         First = 1
      else:
         print("%-24s:" % "", end=" ")
      print(FormatPGPKey(x))


# Print the SSH RSA Authentication keys for a user
def PrintSshRSAKeys(Attrs):
   if "sshRSAAuthKey" not in Attrs[1]:
      return
   First = 0
   for x in Attrs[1]["sshRSAAuthKey"]:
      if First == 0:
         print("%-24s:" % "SSH Auth Keys", end=" ")
         First = 1
      else:
         print("%-24s:" % "", end=" ")

      print(FormatSSHAuth(x.decode('ascii')))


# Display all of the attributes in a numbered list
def ShowAttrs(Attrs):
   print()
   print(EmailAddress(Attrs))
   PrintModTime(Attrs)
   PrintShadow(Attrs)
   PrintKeys(Attrs)
   PrintSshRSAKeys(Attrs)

   for at in Attrs[1].keys():
      if at in AttrInfo:
         if AttrInfo[at][1] == 0:
            print("      %-18s:" % AttrInfo[at][0], end=" ")
            for x in Attrs[1][at]:
               print("'%s'" % x.decode('utf-8'), end=" ")
            if at == "uid":
               print("(id=%s, gid=%s)" % (GetAttr(Attrs, "uidNumber", "-1"), GetAttr(Attrs, "gidNumber", "-1")), end=" ")
            print()
         else:
            OrderedIndex[AttrInfo[at][1]][1] = Attrs[1][at]

   Keys = sorted(OrderedIndex.keys())
   for at in Keys:
      if at < 100 or RootMode != 0:
         print(" %3u) %-19s: " % (at, OrderedIndex[at][0]), end=" ")
         for x in OrderedIndex[at][1]:
            print("'%s'" % (re.sub('[\n\r]', '?', x.decode('utf-8'))), end=" ")
         print()


# Change a single attribute
def ChangeAttr(Attrs, Attr):
   if (Attr == "supplementaryGid" or Attr == "allowedHost"
       or Attr == "member" or Attr == "dnsZoneEntry" or Attr == "mailWhitelist"
       or Attr == "mailRBL" or Attr == "mailRHSBL"):
      return MultiChangeAttr(Attrs, Attr)

   print("Old value: '%s'" % GetAttr(Attrs, Attr, ""))
   print("Press enter to leave unchanged and a single space to set to empty")
   NewValue = input("New? ")

   # Empty string
   if (NewValue == ""):
      print("Leaving unchanged.")
      return

   # Single space designates delete, trap the delete error
   if (NewValue == " "):
      print("Deleting.", end="")
      try:
         lc.modify_s(UserDn, [(ldap.MOD_DELETE, Attr, None)])
      except ldap.NO_SUCH_ATTRIBUTE:
         pass

      print()
      Attrs[1][Attr] = [""]
      return

   if (Attr == "mailGreylisting" or Attr == "mailCallout"):
      if (NewValue.lower() != "true" and NewValue.lower() != "false"):
         if (NewValue == "1"):
            NewValue = "true"
         else:
            if (NewValue == "0"):
               NewValue = "false"
            else:
               print("Need a boolean value")
               return
      NewValue = NewValue.upper()

   # Set a new value
   print("Setting.", end="")
   NewValue = NewValue.encode('utf-8')
   lc.modify_s(UserDn, [(ldap.MOD_REPLACE, Attr, NewValue)])
   Attrs[1][Attr] = [NewValue]
   print()


def MultiChangeAttr(Attrs, Attr):
   # Make sure that we have an entry
   if Attr not in Attrs[1]:
      Attrs[1][Attr] = []

   Attrs[1][Attr].sort()
   print("Old values: ", [a.decode('utf-8') for a in Attrs[1][Attr]])

   Mode = input("[D]elete or [A]dd? ").upper()
   if (Mode != 'D' and Mode != 'A'):
      return

   NewValue = input("Value? ")
   # Empty string
   if (NewValue == ""):
      print("Leaving unchanged.")
      return

   NewValue = NewValue.encode('utf-8')

   # Delete
   if (Mode == "D"):
      print("Deleting.", end=" ")
      try:
         lc.modify_s(UserDn, [(ldap.MOD_DELETE, Attr, NewValue)])
      except ldap.NO_SUCH_ATTRIBUTE:
         print("Failed")

      print()
      Attrs[1][Attr].remove(NewValue)
      return

   # Set a new value
   print("Setting.", end="")
   lc.modify_s(UserDn, [(ldap.MOD_ADD, Attr, NewValue)])
   Attrs[1][Attr].append(NewValue)
   print()


def Lock(UserDn, Attrs, DisableMail=True):
   shadowLast = str(int(time.time() / 24 / 60 / 60))
   recs = [
       (ldap.MOD_REPLACE, "userPassword", "{crypt}*LK*"),
       (ldap.MOD_REPLACE, "shadowLastChange", shadowLast),
       (ldap.MOD_REPLACE, "shadowExpire", "1")]
   if DisableMail:
      recs.append((ldap.MOD_REPLACE, "mailDisableMessage", "account locked"))
      Attrs[0][1]["mailDisableMessage"] = ["account locked"]
   lc.modify_s(UserDn, recs)
   Attrs[0][1]["userPassword"] = ["{crypt}*LK*"]
   Attrs[0][1]["shadowLastChange"] = [shadowLast]
   Attrs[0][1]["shadowExpire"] = ["1"]


# Main program starts here
User = pwd.getpwuid(os.getuid())[0]
BindUser = User
# Process options
try:
   (options, arguments) = getopt.getopt(sys.argv[1:], "nu:c:a:r")
except getopt.GetoptError as data:
   print(data)
   sys.exit(1)

for (switch, val) in options:
   if (switch == '-u'):
      User = val
   elif (switch == '-a'):
      BindUser = val
   elif (switch == '-c'):
      BindUser = val
      User = val
   elif (switch == '-r'):
      RootMode = 1
   elif (switch == '-n'):
      BindUser = ""

if (BindUser != ""):
   print("Accessing LDAP entry for '" + User + "'", end=" ")
if (BindUser != User):
   if (BindUser != ""):
      print("as '" + BindUser + "'")
else:
   print()

# Connect to the ldap server
lc = connectLDAP()
UserDn = "uid=" + User + "," + BaseDn
if (BindUser != ""):
   Password = getpass.getpass(BindUser + "'s password: ")
   BindUserDn = "uid=" + BindUser + "," + BaseDn
else:
   Password = ""
   BindUserDn = ""
try:
   lc.simple_bind_s(BindUserDn, Password)
except ldap.LDAPError as e:
   print("LDAP error:", e.args[0]['desc'], file=sys.stderr)
   print("           ", e.args[0]['info'], file=sys.stderr)
   sys.exit(1)

# Enable changing of supplementary gid's
if (RootMode == 1):
   # Items that root can edit
   list = ["supplementaryGid", "allowedHost", "member"]
   Count = 0
   for x in list:
      AttrInfo[x][1] = 200 + Count
      OrderedIndex[AttrInfo[x][1]] = [AttrInfo[x][0], "", x]
      OrigOrderedIndex[AttrInfo[x][1]] = [AttrInfo[x][0], "", x]
      Count = Count + 1

# Query the server for all of the attributes
Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + User)
if len(Attrs) == 0:
   print("User", User, "was not found.")
   sys.exit(0)

# repeatedly show the account configuration
while(1):
   ShowAttrs(Attrs[0])
   if (BindUser == ""):
      sys.exit(0)

   if RootMode == 1:
      print("   a) Arbitary Change")
      print("   r) retire developer")
      print("   R) Randomize Password")
      print("   L) Lock account and disable mail")
   print("   p) Change Password")
   print("   u) Switch Users")
   print("   x) Exit")

   # Prompt
   Response = input("Change? ")
   if (Response == "x" or Response == "X" or Response == "q"
       or Response == "quit" or Response == "exit"):
      break

   # Change who we are looking at
   if (Response == 'u' or Response == 'U'):
      NewUser = input("User? ")
      if NewUser == "":
         continue
      NAttrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + NewUser)
      if len(NAttrs) == 0:
         print("User", NewUser, "was not found.")
         continue
      Attrs = NAttrs
      User = NewUser
      UserDn = "uid=" + User + "," + BaseDn
      OrderedIndex = copy.deepcopy(OrigOrderedIndex)
      continue

   # Handle changing the password
   if (Response == "p"):
      print("Please enter a new password. Your password can be of unlimited length,")
      print("contain spaces and other special characters. No checking is done on the")
      print("strength of the passwords so pick good ones please!")

      Pass1 = getpass.getpass(User + "'s new password: ")
      Pass2 = getpass.getpass(User + "'s new password again: ")
      if Pass1 != Pass2:
         print("Passwords did not match")
         input("Press a key")
         continue

      try:
         Pass = HashPass(Pass1)
      except Exception:
         print("%s: %s\n" % sys.exc_info()[:2])
         input("Press a key")
         continue

      print("Setting password..")
      Pass = "{crypt}" + Pass
      shadowLast = str(int(time.time() / 24 / 60 / 60))
      lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "userPassword", Pass),
                           (ldap.MOD_REPLACE, "shadowLastChange", shadowLast)])
      Attrs[0][1]["userPassword"] = [Pass]
      Attrs[0][1]["shadowLastChange"] = [shadowLast]
      continue

   # retire DD
   if Response == 'r' and RootMode == 1:
      if "accountStatus" not in Attrs[0][1]:
        curStatus = "<not set>"
      else:
        curStatus = Attrs[0][1]["accountStatus"][0]
      if "accountComment" not in Attrs[0][1]:
        curComment = "<not set>"
      else:
        curComment = Attrs[0][1]["accountComment"][0]
      print("\n\nCurrent status is %s" % curStatus)
      print("Current comment is %s\n" % curComment)

      print("Set account to:")
      print("  1) retiring (lock account but do not disable mail):")
      print("  2) inactive (removed/emeritus/... - lock account and disable mail):")
      print("  3) memorial (lock account and disable mail):")
      print("  4) active (do not change other settings, you will have to deal with them)")
      print("  q) return (no change)")
      Resp = input("Action? ")
      if Resp == "1" or Resp == "2":
         Lock(UserDn, Attrs, Resp == "2")
         if Resp == "1":
           newstatus = "retiring %s" % time.strftime("%Y-%m-%d")
         else:
           newstatus = "inactive %s" % time.strftime("%Y-%m-%d")
         lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "accountStatus", newstatus)])
         Attrs[0][1]["accountStatus"] = [newstatus]

         Resp2 = input("Optional RT ticket number? ")
         if (Resp2 != ''):
           comment = "RT#%s" % Resp2
           lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "accountComment", comment)])
           Attrs[0][1]["accountComment"] = [comment]
      elif Resp == "3":
         Lock(UserDn, Attrs)
         newstatus = "memorial"
         lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "accountStatus", newstatus)])
         Attrs[0][1]["accountStatus"] = [newstatus]
      elif Resp == "4":
         newstatus = "active"
         lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "accountStatus", newstatus)])
         Attrs[0][1]["accountStatus"] = [newstatus]

      continue

   # Randomize password
   if Response == 'R' and RootMode == 1:
      Resp = input("Randomize Users Password? [no/yes]")
      if Resp != "yes":
         continue

      # Generate a random password
      try:
         Password = GenPass()
         Pass = HashPass(Password)
      except Exception:
         print("%s: %s\n" % sys.exc_info()[:2])
         input("Press a key")
         continue

      print("Setting password..")
      Pass = "{crypt}" + Pass
      shadowLast = str(int(time.time() / 24 / 60 / 60))
      lc.modify_s(UserDn, [(ldap.MOD_REPLACE, "userPassword", Pass),
                           (ldap.MOD_REPLACE, "shadowLastChange", shadowLast)])
      Attrs[0][1]["userPassword"] = [Pass]
      Attrs[0][1]["shadowLastChange"] = [shadowLast]
      continue

   # Lock account
   if Response == 'L' and RootMode == 1:
      Resp = input("Really lock account? [no/yes]")
      if Resp != "yes":
         continue

      print("Setting password..")
      Lock(UserDn, Attrs)
      continue

   # Handle changing an arbitary value
   if (Response == "a"):
      Attr = input("Attr? ")
      ChangeAttr(Attrs[0], Attr)
      continue

   # Convert the integer response
   try:
      ID = int(Response)
      if (ID not in OrderedIndex or (ID > 100 and RootMode == 0)):
         raise ValueError
   except ValueError:
      print("Invalid")
      continue

   # Print the what to do prompt
   print("Changing LDAP entry '%s' (%s)" % (OrderedIndex[ID][0], OrderedIndex[ID][2]))
   print(AttrPrompt[OrderedIndex[ID][2]][0])
   ChangeAttr(Attrs[0], OrderedIndex[ID][2])
