#!/usr/bin/env python
# -*- coding: utf-8 -*-

#
#    LinOTP - the open source solution for two factor authentication
#    Copyright (C) 2010 - 2014 LSE Leading Security Experts GmbH
#
#    This file is part of LinOTP server.
#
#    This program is free software: you can redistribute it and/or
#    modify it under the terms of the GNU Affero General Public
#    License, version 3, as published by the Free Software Foundation.
#
#    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/>.
#
#
#    E-mail: linotp@lsexperts.de
#    Contact: www.linotp.org
#    Support: www.lsexperts.de
#
"""This tool is used to test a radius connection with
                support for challenge response

  Dependencies: pyrad

"""

import sys
import os
from os import path, access, R_OK

import traceback
import getpass
import ConfigParser

import pyrad.packet
from pyrad.client import Client
from pyrad.dictionary import Dictionary

from getopt import getopt, GetoptError

import logging

logging.basicConfig(
    level=logging.WARNING,
    format="%(asctime)s %(levelname)-5.5s "
                "[%(name)s][%(funcName)s #%(lineno)d] %(message)s"
    )

VOID_RADIUS_SECRET = "voidRadiusSecret"
DEFAULT_CONFIG = "%s%s.auth-radius.ini" % (os.environ['HOME'], os.sep)

log = logging.getLogger(__name__)

def usage(prog):
    """
    print the usage info

    :param prog:
    """
    intro(prog)
    print "Radius Client to test the radius authentication, incl. CR authentication"
    print  '''
Parameter:
    '-f' or '--file':     the linotp ini with the radius dict and the nasid
    '-d' or '--dict':     the radius dictionary - required, if no ini file specified
    '-n' or '--nas':      the nas identifier - optional or taken from the ini file
    '-r' or '--radius':   the radius server by ip:port or host:port
    '-s' or '--secret':   the radius communication secret
    '-l' or '--log':      set the log level: DEBUG, INFO, ERROR or WARNING
    '-c' or '--config':   will retrieve and store the radius data from a config file
                          while additional parameters overrule and overwrite these
    '-i' or '--interactive':    in case of a challenge response this will request
                                for the challenge response
    '-u' or '--user':     the user
    '-p' or '--password': the password of the user (pin or pin+otp)
    '-o' or '--otp':      the otp for the two step challenge response authentication
    '-t' or '--state':    the state from a former challenge
    '-h' or '--help':     usage info - this text

Example:

      Test authentication against

       * radius server 192.168.56.123
       * with radius secret Test123!
       * use the src/test.ini file for identifiying the radius dictionary
       * check for user horst
       * with password ttt1234

      linotp-auth-radius -f src/test.ini -s Test123! -r 192.168.56.123 \\
                                      -u horst -p ttt1234

    after a first call like this with the additional option -c:

      linotp-auth-radius -c -f src/test.ini -s Test123! -r 192.168.56.123 \\
                                      -u horst -p ttt1234

    the radius attributes are stored in a local config file
    and could then be reused by a shorter commandline  like

      linotp-auth-radius -c -u horst -p ttt1234

    A challenge response is triggered by only providing the pin as password,
    the otp will therefore be provided as a separate option

      linotp-auth-radius -i -c -u horst -p ttt -o 243444

    or
       linotp-auth-radius -i -c -u horst -p ttt

    which will ask you to enter the otp value


    For doing the challenge response in a two step batch calling,
    you can use the following commands

      linotp-auth-radius -c -u horst -p ttt

    which will return the following similar reply:

        Access rejected for user horst.
        State:'794560860539'
        Reply-Message:'Please enter your otp value: '

    Then in the second step you can enter the otp and state using the -t option

       linotp-auth-radius -c -u horst -p 798811 -t 794560860539

    '''

    return

def intro(prog):
    """
    print the headline of the program

    :param prog: the program name
    """
    p_name = prog.rsplit(os.sep, 1)[1]
    p_name = p_name.rsplit('.')[0]
    hline = (len(p_name)) * "="
    print hline
    print "%s" % p_name
    print hline

    return

def saveConfig(config_file, section, config_dict):
    """
    save the current configuration for shortening the re-run

    :param config_file: the config file name
    :param section: the config file section
    :param config: the config dictionary
    """
    cfgfile = None

    config = ConfigParser.ConfigParser()

    try:
        cfgfile = open(config_file, 'w')

        config.add_section(section)

        for key in config_dict:
            config.set(section, key, config_dict.get(key))

        config.write(cfgfile)

    except Exception as exx:
        log.error("Failed to save config %r" % exx)
        log.error(traceback.format_exc())

    finally:
        if cfgfile is not None:
            cfgfile.close()

    return

def loadConfig(ini_file, section):
    """
    load the linotp config .ini file

    :param file: the ini filename
    :param section: the section of interesst, which is here app:main
    :return: dictionary with all config entries
    """
    log.debug("Loading Config")

    config = ConfigParser.ConfigParser()
    if os.path.isfile(ini_file) == False:
        raise Exception("File not found: %s" % ini_file)

    config.read(ini_file)

    ## replace the here with the current path
    config_path = os.path.abspath(os.path.dirname(ini_file))
    config.set("DEFAULT", "here", config_path)

    ## extract section as a dict
    public = getConfigSection(config, section)

    return public

def getConfigSection(config, section):
    """
    return a config section as a dict

    :param Config: the parsed config object
    :param section: the name of the section to be dumped
    :return: dictionary with all key, value pairs of a section
    """
    dict1 = {}
    try:
        options = config.options(section)
        for option in options:
            dict1[option] = config.get(section, option)
            if dict1[option] == -1:
                log.debug("skip: %s" % option)
    except:
        log.error("exception on %s!" % option)
    return dict1



class RadiusClient(object):
    """
    radius authetication client to support challenge response
    """

    def __init__(self, rServer=None, rSecret=None, rNasId=None, rDict=None):
        """
        constructor to create server connection

        :param rServer: radius server spec ip:port or hostname:port
        :param rSecret: the radius communication secret
        :param rNasId: the radius nas identifier
        :param rDict: the radius dict for attribute -id maping

        """
        self.radius_secret = rSecret
        self.radius_nasid = rNasId
        self.radius_dict = rDict
        self.radius_authport = 1812

        ## extract the radius port from the server spec
        server = rServer.split(':')
        self.radius_server = server[0]
        if len(server) >= 2:
            self.radius_authport = int(server[1])

        log.debug("NAS Identifier: %r, Dictionary: %r" %
                    (self.radius_nasid, self.radius_dict))

        log.debug("Constructing client object with server:"
                    " %r, port: %r, secret: %r" %
           (self.radius_server, self.radius_authport, self.radius_secret))

        if self.radius_secret == VOID_RADIUS_SECRET:
            log.warning("Usage of default radius secret is not recomended!!")


    def connect(self):
        """
        create the server connection

        :return: the srv connection, which is used to create request
        """
        client_params = {
                    "server" :self.radius_server,
                    "secret" : self.radius_secret,
                    "authport" : self.radius_authport,
                    }

        if self.radius_dict is not None:
            client_params["dict"] = Dictionary(self.radius_dict)

        srv = Client(**client_params)

        return srv

    def authenticate(self, user=None, passw=None, state=None):
        """
        run an authentication request against the radius server

        :param user: the user, who should be authenticated
        :param passw: the password of the user, which could be either pin
                      or pin+otp
        :param state: the transaction id, which is used in Challenge Response
        :return: tupple of ( boolean, dict or None)
                 true indicates success
                 false indicated reject
                 - if dict is not None, it contains the challenge
        """

        res = False
        opt = None

        try:

            srv = self.connect()

            request_params = {}
            request_params["code"] = pyrad.packet.AccessRequest
            request_params["User_Name"] = user.encode('ascii')

            if self.radius_nasid is not None:
                request_params["NAS_Identifier"] = \
                    self.radius_nasid.encode('ascii')

            req = srv.CreateAuthPacket(**request_params)

            ## do the request
            req["User-Password"] = req.PwCrypt(passw)
            if state is not None:
                req["State"] = state

            log.debug("Sending radius packet ... ")
            reply = srv.SendPacket(req)

            ## prepare the response
            rcode = reply.code
            log.debug("Received code %r" % rcode)

            if rcode == pyrad.packet.AccessChallenge:
                opt = {}
                for attr in reply.keys():
                    opt[attr] = reply[attr]
                res = False
                log.debug("challenge returned %r " % opt)

            elif reply.code == pyrad.packet.AccessAccept:
                log.debug("acces accept")
                res = True
            else:
                log.debug("acces reject")
                res = False

        except Exception as exx:
            log.error("Error contacting radius Server: %r" % (exx))
            log.error(traceback.format_exc())

        return (res, opt)

def authenticate(user, passw, otp, state, param, interactive=False):
    """
    make the authentication happen

    :param user: the user
    :param passw: the user pasword (pin or pin+otp)
    :param otp: the otp - for challenge the otp is requested in a second step
    :param param: dictionary of the radius configuration
    :return: - nothing -
    """
    ret = -1

    radcli = RadiusClient(**param)
    (res, opt) = radcli.authenticate(user=user, passw=passw, state=state)

    if interactive == True:
        ## as long as we are challenged again..
        while opt is not None:

            print("Received challenge for user %s" % (user))
            state = opt.get("State", None)
            message = '>>%s' % opt.get("Reply-Message", "")

            ## if there is a dedicated otp, we will use it in the first challenge
            if otp is not None:
                print message
                passw = otp
                ## give it a one shot only
                otp = None
            else:
                passw = getpass.getpass(message)

            ## do the challenge authentication with the additional state attribute
            (res, opt) = radcli.authenticate(user=user, passw=passw, state=state)
    else:
        if otp is not None and opt is not None:
            state = opt.get("State", None)
            message = '>>%s' % opt.get("Reply-Message", "")
            (res, opt) = radcli.authenticate(user=user, passw=otp, state=state)

    if res == True:
        print("Access granted to user %s." % (user))
        ret = 0
    else:
        print("Access rejected for user %s." % (user))
        ret = -1

    if opt is not None and len(opt) > 0:
        for key, value in opt.items():
            print("%s:%r" % (key, value))

    return ret

def main():
    """
    main worker:
    * gather the input
    """
    param = {}
    ini_file = None
    config_file = None
    passw = None
    user = None
    otp = None
    interactive = False
    state = None

    prog = sys.argv[0]

    try:
        opts, args = getopt(sys.argv[1:], "f:r:s:l:u:p:o:d:n:t:hcie",
                        ["file=", "radius=", "secret=", "log=", "user=",
                         "password=", "otp=", "dict=", "nas=", "state="])

    except GetoptError:
        print "There is an error in your parameter syntax:"
        usage(prog)
        sys.exit(1)

    for opt, arg in opts:
        if opt in ('-f', '--file'):
            ini_file = arg
        elif opt in ('-c', '--config'):
            config_file = DEFAULT_CONFIG
        elif opt in ('-r', '--radius'):
            param["rServer"] = arg
        elif opt in ('-d', '--dict'):
            param["rDict"] = arg
        elif opt in ('-n', '--nas'):
            param["rNasId"] = arg
        elif opt in ('-s', '--secret'):
            param["rSecret"] = arg
        elif opt in ('-i', '--interactive'):
            interactive = True
        elif opt in ('-e', '--environment'):
            env = os.environ
            for entry, value in env.items():
                print "%s=%s" % (entry, value)
        elif opt in ('-l', '--log'):
            global log
            log_level = arg.upper()[0]
            if log_level == "I":
                log.level = logging.INFO
            elif log_level == "D":
                log.level = logging.DEBUG
            elif log_level == "E":
                log.level = logging.ERROR
            else:
                log.level = logging.WARNING

        elif opt in ('-u', '--user'):
            user = arg
        elif opt in ('-p', '--password'):
            passw = arg
        elif opt in ('-o', '--otp'):
            otp = arg
        elif opt in ('-t', '--state'):
            state = arg
        elif opt in ('-h', '--help'):
            usage(prog)
            sys.exit(1)

    ###################################################################

    if interactive is True:
        intro(prog)

    if (config_file is not None and
            path.isfile(config_file) and access(config_file, R_OK)):
        try:
            config = loadConfig(config_file, "app:main")
            if "rDict" not in param:
                param["rDict"] = config.get('rdict', None)
            if "rNasId" not in param:
                param["rNasId"] = config.get('rnasi', None)
            if "rSecret" not in param:
                param["rSecret"] = config.get('rsecret', None)
            if "rServer" not in param:
                param["rServer"] = config.get('rserver', None)
        except Exception as exx:
            log.info("Error reading configuration file %r" % exx)

    if ini_file is not None:
        config = loadConfig(ini_file, 'app:main')
        if "rDict" not in param:
            param["rDict"] = config.get('radius.dictfile', None)
        if "rNasId" not in param:
            param["rNasId"] = config.get('radius.nas_identifier', None)

    if "rDict" not in param:
        print "Missing radius dict specification - stop working"
        sys.exit(1)

    if "rNasId" not in param:
        log.warning("Missing radius nas specification")

    if "rSecret" not in param and  interactive is True:
        param["rSecret"] = getpass.getpass("Please enter radius secret: ")

    if user is None and interactive is True:
        user = raw_input("Please enter user: ")

    if passw is None and interactive is True:
        prompt = "Please enter password for user %s: " % user
        passw = getpass.getpass(prompt)

    if config_file is not None :
        saveConfig(config_file, "app:main", param)

    # config done #############################################################
    if user is None or passw is None or "rSecret" not in param:
        print("missing parameters: user %r, passw %r or radius secret %r" %
                (user, passw, param.get("rSecret", None)))
        sys.exit(-1)

    res = authenticate(user, passw, otp, state, param, interactive)

    return res


if __name__ == '__main__':
    ## jump to the main worker
    main()

##eof##########################################################################
