/*
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 *
 */

package org.apache.qpid.server.security.auth.sasl;

import junit.framework.TestCase;
import org.apache.commons.codec.binary.Hex;

import org.apache.qpid.server.security.auth.database.Base64MD5PasswordFilePrincipalDatabase;
import org.apache.qpid.server.security.auth.sasl.crammd5.CRAMMD5HexInitialiser;
import org.apache.qpid.server.security.auth.sasl.crammd5.CRAMMD5HexSaslServer;
import org.apache.qpid.server.security.auth.sasl.crammd5.CRAMMD5HexServerFactory;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.security.auth.login.AccountNotFoundException;
import javax.security.sasl.SaslException;
import javax.security.sasl.SaslServer;
import java.io.File;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.Principal;

/**
 * Test for the CRAM-MD5-HEX SASL mechanism.
 *
 * This test case focuses on testing {@link CRAMMD5HexSaslServer} but also exercises
 * collaborators {@link CRAMMD5HexInitialiser} and {@link Base64MD5PasswordFilePrincipalDatabase}
 */
public class CRAMMD5HexServerTest extends TestCase
{

    private SaslServer _saslServer;  // Class under test
    private CRAMMD5HexServerFactory _saslFactory;

    @Override
    protected void setUp() throws Exception
    {
        super.setUp();

        CRAMMD5HexInitialiser _initializer = new CRAMMD5HexInitialiser();

        //Use properties to create a PrincipalDatabase
        Base64MD5PasswordFilePrincipalDatabase db = createTestPrincipalDatabase();
        assertEquals("Unexpected number of test users in the db", 2, db.getUsers().size());

        _initializer.initialise(db);

        _saslFactory = new CRAMMD5HexServerFactory();

        _saslServer = _saslFactory.createSaslServer(CRAMMD5HexSaslServer.MECHANISM,
                "AMQP",
                "localhost",
                null,
                _initializer.getCallbackHandler());
        assertNotNull("Unable to create saslServer with mechanism type " + CRAMMD5HexSaslServer.MECHANISM, _saslServer);

    }

    public void testSuccessfulAuth() throws Exception
    {

        final byte[] serverChallenge = _saslServer.evaluateResponse(new byte[0]);

        // Generate client response
        final byte[] clientResponse = generateClientResponse("knownuser", "guest", serverChallenge);


        byte[] nextServerChallenge = _saslServer.evaluateResponse(clientResponse);
        assertTrue("Exchange must be flagged as complete after successful authentication", _saslServer.isComplete());
        assertNull("Next server challenge must be null after successful authentication", nextServerChallenge);

    }

    public void testKnownUserPresentsWrongPassword() throws Exception
    {
        byte[] serverChallenge = _saslServer.evaluateResponse(new byte[0]);


        final byte[] clientResponse = generateClientResponse("knownuser", "wrong!", serverChallenge);
        try
        {
            _saslServer.evaluateResponse(clientResponse);
            fail("Exception not thrown");
        }
        catch (SaslException se)
        {
            // PASS
        }
        assertFalse("Exchange must not be flagged as complete after unsuccessful authentication", _saslServer.isComplete());
    }

    public void testUnknownUser() throws Exception
    {
        final byte[] serverChallenge = _saslServer.evaluateResponse(new byte[0]);


        final byte[] clientResponse = generateClientResponse("unknownuser", "guest", serverChallenge);

        try
        {
            _saslServer.evaluateResponse(clientResponse);
            fail("Exception not thrown");
        }
        catch (SaslException se)
        {
            assertExceptionHasUnderlyingAsCause(AccountNotFoundException.class, se);
            // PASS
        }
        assertFalse("Exchange must not be flagged as complete after unsuccessful authentication", _saslServer.isComplete());
    }

    /**
     *
     * Demonstrates QPID-3158.  A defect meant that users with some valid password were failing to 
     * authenticate when using the .NET 0-8 client (uses this SASL mechanism).  
     * It so happens that password "guest2" was one of the affected passwords.
     *
     * @throws Exception
     */
    public void testSuccessfulAuthReproducingQpid3158() throws Exception
    {
        byte[] serverChallenge = _saslServer.evaluateResponse(new byte[0]);

        // Generate client response
        byte[] resp = generateClientResponse("qpid3158user", "guest2", serverChallenge);

        byte[] nextServerChallenge = _saslServer.evaluateResponse(resp);
        assertTrue("Exchange must be flagged as complete after successful authentication", _saslServer.isComplete());
        assertNull("Next server challenge must be null after successful authentication", nextServerChallenge);
    }

    /**
     * Since we don't have a CRAM-MD5-HEX implementation client implementation in Java, this method
     * provides the implementation for first principals.
     *
     * @param userId user id
     * @param clearTextPassword clear text password
     * @param serverChallenge challenge from server
     *
     * @return challenge response
     */
    private byte[] generateClientResponse(final String userId, final String clearTextPassword, final byte[] serverChallenge) throws Exception
    {
        byte[] digestedPasswordBytes = MessageDigest.getInstance("MD5").digest(clearTextPassword.getBytes());
        char[] hexEncodedDigestedPassword = Hex.encodeHex(digestedPasswordBytes);
        byte[] hexEncodedDigestedPasswordBytes = new String(hexEncodedDigestedPassword).getBytes();


        Mac hmacMd5 = Mac.getInstance("HmacMD5");
        hmacMd5.init(new SecretKeySpec(hexEncodedDigestedPasswordBytes, "HmacMD5"));
        final byte[] messageAuthenticationCode = hmacMd5.doFinal(serverChallenge);

        // Build client response
        String responseAsString = userId + " " + new String(Hex.encodeHex(messageAuthenticationCode));
        byte[] resp = responseAsString.getBytes();
        return resp;
    }

    /**
     * Creates a test principal database.
     *
     * @return
     * @throws IOException
     */
    private Base64MD5PasswordFilePrincipalDatabase createTestPrincipalDatabase() throws IOException
    {
        Base64MD5PasswordFilePrincipalDatabase db = new Base64MD5PasswordFilePrincipalDatabase();
        File file = File.createTempFile("passwd", "db");
        file.deleteOnExit();
        db.open(file);
        db.createPrincipal( createTestPrincipal("knownuser"), "guest".toCharArray());
        db.createPrincipal( createTestPrincipal("qpid3158user"), "guest2".toCharArray());
        return db;
    }

    private Principal createTestPrincipal(final String name)
    {
        return new Principal()
        {
            public String getName()
            {
                return name;
            }
        };
    }
    
    private void assertExceptionHasUnderlyingAsCause(final Class<? extends Throwable> expectedUnderlying, Throwable e)
    {
        assertNotNull(e);
        int infiniteLoopGuard = 0;  // Guard against loops in the cause chain
        boolean foundExpectedUnderlying = false;
        while (e.getCause() != null && infiniteLoopGuard++ < 10)
        {
            if (expectedUnderlying.equals(e.getCause().getClass()))
            {
                foundExpectedUnderlying = true;
                break;
            }
            e = e.getCause();
        }
        
        if (!foundExpectedUnderlying)
        {
            fail("Not found expected underlying exception " + expectedUnderlying + " as underlying cause of " + e.getClass());
        }
    }

}
