<?php
/*
 * PiAnoS (Picture Annotation System), A fingerprint examiner training tool
 * Copyright (C) 2011-2021 University of Lausanne, Switzerland
 * https://ips-labs.unil.ch/pianos
 *
 * 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/.
 */
require_once ( "unil/Node.php" );
class Login extends Node
{
    function __construct( $errors )
    {
        $this->errors = $errors;
    }
    function render( &$rs )
    {
        // Theme-related CSS
        $rs->getPage()->addStyleSheet( Application::get( 'csslib_jquery_ui' ) );
        $rs->setJQueryStyleSheet( Application::get( 'jqueryui_theme' ), 'theme-backend.css' );
        // Theme agnostic CSS
        $rs->getPage()->addStyleSheet( 'css/backend.css' );
        
        $rs->getPage()->addScript( Application::get( 'jslib_md5' ) );
        
        $rs->getPage()->setNoScript( '<div class="ui-widget ui-widget-content ui-corner-bottom ui-state-error" style="font-size:1.1em;text-align:center;padding:0.5em;">You must enable Javascript for this application to function !</div>' );
        
        $username = '';
        if( isset( $_POST[ 'username' ] ) )
            $username = $_POST[ 'username' ];
        
        $challenge = $_SESSION[ 'security' ][ 'login_challenge' ];
        
        $verstring = Application::get( 'version' );
        
        $this->domStart( 'div', NULL, array (), 'style="text-align:center;width:100%;padding-top:2em"' );
        $this->domStart( 'div', NULL, array (
                'ui-widget', 
                'login_panel' 
        ) );
        $this->domStart( 'div', NULL, array (
                'ui-widget-header', 
                'ui-corner-top' 
        ), 'style="text-align:center;margin-bottom:0;"' );
        echo _( "Please enter your login information" );
        $this->domClose();
        $this->domStart( 'div', NULL, array (
                'ui-widget-content', 
                'ui-corner-bottom' 
        ) );
        $this->domStart( 'form', 'login_form', NULL, 'action="?" method="post"' );
        $this->domStart( 'fieldset', NULL, array (
                'data_panel_section' 
        ), 'style="border:none;"' );
        $this->domStart( 'div' );
        $this->domElement( 'label', _H( _( "Username" ) ), NULL, NULL, ' for="username"' );
        $this->domElement( 'input', NULL, 'username', NULL, 'type="text" name="username" value="' . _H( $username ) . '" disabled="disabled"' );
        $this->domClose();
        $this->domStart( 'div' );
        $this->domElement( 'label', _H( _( "Password" ) ), NULL, NULL, ' for="password"' );
        $this->domElement( 'input', NULL, 'password', NULL, 'type="password" name="password" value="" disabled="disabled"' );
        $this->domClose();
        $this->domElement( 'input', NULL, 'challenge', NULL, 'type="hidden" name="challenge" value="' . _H( $challenge ) . '"' );
        $this->domStart( 'div', NULL, array (), 'style="text-align:center;margin-top:0.5em;"' );
        $this->domElement( 'input', NULL, 'submit', NULL, 'type="submit" value="' . _H( _( "Login" ) ) . '" disabled="disabled"' );
        $this->domClose();
        $this->domClose();
        $this->domClose();
        if( count( $this->errors ) )
        {
            $this->domStart( 'div' );
            foreach( $this->errors as $err )
                $this->domElement( 'p', _H( $err ), NULL, array (
                        'ui-state-error', 
                        'ui-corner-all' 
                ), 'style="margin:0.5em; padding: 0 0.5em;"' );
            $this->domClose();
        }
        $this->domStart( 'div', NULL, array (
                'bottom_block' 
        ) );
        $this->domElement( 'span', 'PiAnoS', NULL,  array (
            'pianos_version'
        ));
        $this->domElement( 'span', $verstring, "version", array (
            'pianos_version' 
        ) );
        $this->domElement( 'span', '', "branch", array (
            'pianos_version' 
        ) );
        $languages = Application::instance()->getDB()->getLanguages();
        $this->domStart( 'div', NULL, array (
                'lang_selector' 
        ) );
        $this->domStart( 'ul' );
        $i = 0;
        foreach( $languages as $lang )
        {
            $this->domStart( 'li' );
            if( $i ++  )
                $this->domElement( 'span', ' | ' );
            $this->domElement( 'a', strtoupper( substr( $lang[ 'short' ], 0, 2 ) ), NULL, array (), 'href="?setlocale=' . $lang[ 'short' ] . '"' );
            $this->domClose();
        }
        $this->domClose();
        $this->domClose();
        $this->domClose();
        $this->domClose();
        $this->domClose();
        $this->domClose();
        
        $this->domStartJS();
        echo "
            $(document).ready(function() {
                $('#username').removeAttr('disabled');
                $('#password').removeAttr('disabled');
                $('#submit').removeAttr('disabled').button();
                $('#login_form').submit(function() {
                    $('#password').val(hex_md5(hex_md5($('#username').val()+':'+$('#password').val())+':'+$('#challenge').val()));
                });
            });
            
            $.ajax( {
                url: 'version.php',
                dataType: 'json',
                success: function( data )
                {
                    if( ! data.error )
                    {
                        $( '#version' )
                            .text( data.desc );
                        $( '#branch' )
                            .text( data.branch );
                    }
                },
            } );
        ";
        
        $this->domClose();
        
        $shib = Application::get( 'shibboleth_login' );
        if( $shib && is_array( $shib ) )
        {
            $this->domStart( 'div', NULL, array (), 'style="text-align:center;width:100%;"' );
            $this->domStart( 'div', NULL, array (
                    'ui-widget', 
                    'login_panel' 
            ), 'style="text-align:left;margin:auto;"' );
            $this->domStart( 'div', NULL, array (
                    'ui-widget-header', 
                    'ui-corner-top' 
            ), 'style="text-align:center;margin-bottom:0;"' );
            echo _H( _( $shib[ 'login_prompt' ] ) );
            $this->domClose();
            $this->domStart( 'div', NULL, array (
                    'ui-widget-content', 
                    'ui-corner-bottom' 
            ), 'style="padding:0.5em;text-align:center"' );
            $text = isset( $shib[ 'login_text' ] ) && $shib[ 'login_text' ] ? $shib[ 'login_text' ]: '';
            
            $this->domStart( 'a', NULL, array (), 'href="' . _H( $shib[ 'url' ] ) . '" title="' . ( $text ? _H( $text ): 'Shibboleth' ) . '"' );
            if( isset( $shib[ 'login_image' ] ) && $shib[ 'login_image' ] )
            {
                $this->domElement( 'img', NULL, NULL, array (), 'src="' . $shib[ 'login_image' ] . '" alt="' . ( $text ? _H( $text ): 'Shibboleth' ) . '" style="margin:auto;border:0;"' );
            } else
            {
                $this->domElement( 'span', _H( $text ) );
            }
            $this->domClose();
            $this->domClose();
            $this->domClose();
            $this->domClose();
        }
        // var_dump_pre($_SESSION);
    }
    private $errors;
}
;
function createLoginChallenge()
{
    return md5( mt_rand() . '#' . time() . '#' . $_SERVER[ 'HTTP_USER_AGENT' ] . '#' . $_SERVER[ 'REMOTE_PORT' ] );
}
class LoginDriver extends ContentDriver
{
    function __construct()
    {
        $this->loginParams = array (
                'username', 
                'password' 
        );
    }
    public function accept()
    {
        if( isset( $_SESSION[ 'user' ] ) )
            Application::instance()->getDB()->query( "SELECT set_config('unil.user','" . ( int ) $_SESSION[ 'user' ][ 'id' ] . "',false);" );
        
        return ! isset( $_SESSION[ 'user' ] );
    }
    protected function doHandleRequest()
    {
        if( isset( $_SESSION[ 'user' ] ) )
            throw new Exception( "Assertion failed" );
            
            // This bunch of code is dedicated to make the user experience a bit less frustrating
            // when the session has been destroyed without her intervention - maybe because of a
            // session timeout due to the server config, or because the user logged out from another
            // window. If we do nothing special, requests like 'Save my work please' will be ignored
            // and will return a login form - which is not understandable by the user, since a dialog
            // pops up with HTML code instead of an error message explaining what to do.
        if( isset( $_GET[ 'action' ] ) && $_GET[ 'action' ] == 'rpc' )
        {
            header( "Content-Type: text/plain; charset=utf-8" );
            
            echo json_encode( array (
                    'error' => true, 
                    'type' => 'no_session', 
                    'message' => _( "Your session has expired.\n\nDO NOT CLOSE YOUR BROWSER TAB/WINDOW, DO NOT REFRESH OR GO BACK/FORWARD !\nIf you do, you will lose all data that has not been saved already.\n\nStay calm and do the following (please read until the end before doing anything):\n- Open a new tab in your browser (or a new browser window)\n- Go to the login page\n- Log in as usual\n- Switch to this browser tab/window\n- Dismiss this dialog and try your operation again (e.g. click the 'Save' button again)\n\nIf you see this message again, please ask the teacher for help before you ragequit your browser." ) 
            ) );
            exit();
        }
        
        $shib = Application::get( 'shibboleth_login' );
        
        $isRegularLoginAttempt = $this->loginParams !== NULL && isset( $_POST[ $this->loginParams[ 0 ] ] ) && isset( $_POST[ $this->loginParams[ 1 ] ] );
        $isShibbolethLoginAttempt = $shib && isset( $_SESSION[ 'security' ] ) && isset( $_SESSION[ 'security' ][ 'shibboleth' ] );
        $isLoginAttempt = $isRegularLoginAttempt || $isShibbolethLoginAttempt;
        
        $errors = array ();
        
        if( isset( $_GET[ 'setlocale' ] ) && strlen( $_GET[ 'setlocale' ] ) == 5 )
            $_SESSION[ 'locale_override' ] = substr( $_GET[ 'setlocale' ], 0, 5 );
            
            // throw new Exception(var_dump_str($_SESSION));
            // This loop is artificial, and only serves the purpose of allowing
            // 'break' statements to exit its block - I find it neater than
            // nested if statements, or a try {} catch {} block, in this case.
        while( $isLoginAttempt )
        {
            if( ! isset( $_SESSION[ 'security' ] ) )
                throw new Exception( "Unauthorized log in attempt" );
            
            $loginSuccessful = false;
            
            $db = Application::instance()->getDB();
            
            if( $isRegularLoginAttempt )
            {
                // Handle regular username/password logins
                
                if( ! isset( $_SESSION[ 'security' ][ 'login_challenge' ] ) )
                    throw new Exception( "Unauthorized log in attempt" );
                    
                    // The username is given by the client
                $username = $_POST[ $this->loginParams[ 0 ] ];
                if( strlen( $username ) === 0 )
                {
                    $errors[] = _( 'The username cannot be empty' );
                    break;
                }
                
                // Fetch the password stored in the DB. This is md5(username:password)
                $res = $db->query( "SELECT * FROM person WHERE username = E'" . pg_escape_string( $username ) . "' AND active IS true;" );
                if( count( $res ) != 1 )
                {
                    $errors[] = _( 'Invalid username or password' );
                    break;
                }
                $user = $res[ 0 ];
                $password = $user[ 'password' ];
                $displayName = $user[ 'name' ];
                
                // Get the password sent by the client. It is computed by client-side Javascript code, and is md5(md5(username:password):challenge)
                // We have a double md5 sum because we don't store passwords in clear in the database, so we have actually no idea what the password is in the first place. The challenge is only a way to lower the risk of credentials theft if the connection is not made over HTTPS: the password sent by the client can not be re-used.
                $clientPassword = $_POST[ 'password' ];
                if( strlen( $clientPassword ) !== 32 )
                {
                    $errors[] = _( 'Invalid username or password' );
                    break;
                }
                
                // Now do the same computation on the server-side
                $serverPassword = md5( $password . ':' . $_SESSION[ 'security' ][ 'login_challenge' ] );
                
                $loginSuccessful = $serverPassword === $clientPassword;
            } elseif( $isShibbolethLoginAttempt )
            {
                // Handle Shibboleth logins
                $shibData = $_SESSION[ 'security' ][ 'shibboleth' ];
                unset( $_SESSION[ 'security' ][ 'shibboleth' ] );
                // Check if we know the user
                $res = $db->query( "SELECT * FROM person WHERE username = E'" . pg_escape_string( $shibData[ $shib[ 'username' ] ] ) . "';" );
                if( count( $res ) == 1 )
                {
                    $user = $res[ 0 ];
                    if( $user[ 'active' ] === 't' )
                    {
                        $username = $user[ 'username' ];
                        $displayName = $user[ 'name' ];
                        $loginSuccessful = true;
                    }
                } else
                {
                    // This user is unknown. Check if he has the proper access rights and if yes, find the group he will be assigned to.
                    foreach( $shib[ 'groups' ] as $groupData )
                    {
                        if( preg_match( $groupData[ 1 ], $shibData[ $groupData[ 0 ] ] ) )
                        {
                            $res = $db->query( "SELECT * FROM pgroup WHERE name = E'" . pg_escape_string( $groupData[ 2 ] ) . "';" );
                            if( count( $res ) != 1 )
                                throw new Exception( "Unknwon user group: " . $groupData[ 2 ] );
                            $pgroup = $res[ 0 ];
                            
                            // Set the person data according to the shibboleth values
                            $username = $shibData[ $shib[ 'username' ] ];
                            $displayName = '';
                            foreach( $shib[ 'display_name' ] as $field )
                                $displayName .= $shibData[ $field ] . ' ';
                            $displayName = trim( $displayName );
                            $email = $shibData[ $shib[ 'email' ] ];
                            // The only thing we care about is that this password can not be mistaken for a valid md5 sum.
                            $password = 'shib-' . md5( $username . $displayName . $email );
                            
                            // Impersonate the first admin we can find, create the user with its group membership and then
                            // reset the privilege escalation.
                            $db->query( "BEGIN;" );
                            $db->query( "SELECT set_config('unil.user', (SELECT person.id FROM person,membership,pgroup WHERE person.id = membership.person AND membership.pgroup = pgroup.id AND pgroup.tutors IS true ORDER BY person.id ASC LIMIT 1)::text, false);" );
                            $res = $db->query( "INSERT INTO person (username,name,email,password,language,active,suitability_approach) VALUES (E'" . pg_escape_string( $username ) . "',E'" . pg_escape_string( $displayName ) . "',E'" . pg_escape_string( $email ) . "',E'" . pg_escape_string( $password ) . "',(SELECT id FROM language ORDER BY id LIMIT 1),true,2) RETURNING id;" );
                            $userID = $res[ 0 ][ 'id' ];
                            $db->query( "INSERT INTO membership(pgroup,person) VALUES (" . ( int ) $pgroup[ 'id' ] . "," . ( int ) $userID . ");" );
                            $db->query( "SELECT set_config('unil.user', NULL, false);" );
                            $db->query( "COMMIT;" );
                            $res = $db->query( "SELECT * FROM person WHERE id = " . ( int ) $userID . ";" );
                            $user = $res[ 0 ];
                            $loginSuccessful = true;
                            break;
                        }
                    }
                }
                
                if( ! $loginSuccessful )
                {
                    $errors[] = _( 'You do not have the necessary credentials to access this application. Please contact your administrator.' );
                    break;
                }
            }
            
            if( $loginSuccessful )
            {
                // If clientPassword and serverPassword match, this is OK. Don't forget to clear the challenge code so that we can't reuse this password.
                unset( $_SESSION[ 'security' ][ 'login_challenge' ] );
                $_SESSION[ 'user' ] = array (
                        'id' => ( int ) $user[ 'id' ], 
                        'username' => $username, 
                        'name' => $displayName 
                );
                
                // Set a isAdmin (i.e. isTutor) flag to the session variable - this is quite often needed
                // when rendering the interface. A user is said to be an admin (or tutor) if at least one
                // group he belongs to is a tutors group.
                $res = $db->query( "SELECT bool_or(pgroup.tutors) FROM membership,pgroup WHERE membership.person = " . ( int ) $user[ 'id' ] . " AND membership.pgroup = pgroup.id;" );
                $_SESSION[ 'user' ][ 'isadmin' ] = ( count( $res ) == 1 ) && ( $res[ 0 ][ 'bool_or' ] === 't' );
                
                // Set the locale to the user's prefered language, or preferably to the language set
                // during login.
                if( isset( $_SESSION[ 'locale_override' ] ) )
                {
                    // Set the language for this session only, do not update the user's preference.
                    $_SESSION[ 'unil' ][ 'locale' ] = $_SESSION[ 'locale_override' ];
                } else
                {
                    $res = $db->query( "SELECT short FROM language WHERE id = " . ( int ) $user[ 'language' ] . ";" );
                    if( count( $res ) == 1 )
                        $_SESSION[ 'unil' ][ 'locale' ] = $res[ 0 ][ 'short' ];
                }
                
                // Load the base settings for this user.
                // Also, check for first-time logins and update the last login timestamp.
                $res = $db->query( "SELECT * FROM person WHERE id = " . ( int ) $_SESSION[ 'user' ][ 'id' ] . ";" );
                if( count( $res ) != 1 )
                    throw new Exception( "Access denied" );
                $pdata = $res[ 0 ];
                $_SESSION[ 'is_first_login' ] = $pdata[ 'last_login' ] === NULL;
                $_SESSION[ 'settings' ] = array (
                        'suitability_approach' => $pdata[ 'suitability_approach' ] 
                );
                if( $pdata[ 'must_change_pwd' ] !== 'f' )
                    $_SESSION[ 'security' ][ 'must_change_pwd' ] = 1;
                $db->query( "UPDATE person SET last_login = now() WHERE id = " . ( int ) $_SESSION[ 'user' ][ 'id' ] . ";" );
                
                // Do an HTTP redirect to the home page, so that the user can refresh the page without
                // re-sending the username/password.
                header( "Location: ?" );
                exit();
            } else
            {
                $errors[] = _( 'Invalid username or password' );
            }
            break;
        }
        
        // If we reach this point, whatever the reason, we are not logged in. So create a new
        // login challenge that will be used during the next attempt.
        $_SESSION[ 'security' ][ 'login_challenge' ] = createLoginChallenge();
        
        $login = new Login( $errors );
        $this->printHtml( $login );
    }
    private $loginParams;
}
