/**
* Copyright 2009 Marc Stogaitis and Mimi Sun
*
* Licensed 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.gmote.common.security;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.SocketTimeoutException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Random;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.gmote.common.TcpConnection;
import org.gmote.common.packet.AuthenticationReply;
import org.gmote.common.packet.AuthenticationReq;
/**
* Handles authentication between the client and server. The authentication
* process goes as follows: 1. The user enters his password on the server side
* when the program is installed. 2. The device connects to the server. 3. The
* server sends a 'challenge' to the device. This is a random number. 4. The
* client responds to the challenge by generating a hash of the challenge
* appended to the user's password. 5. The server verifies the client's
* response.
*
* This is a simple approach which is intended to prevent simple attacks, such
* as a roommate taking control of his friend's server because they are on the
* same WiFi network.
*
* @author Marc
*/
public class AuthenticationHandler {
public String getAppVersion() {
return appVersion;
}
private static Logger LOGGER = Logger.getLogger(AuthenticationHandler.class.getName());
private static final String ENCODING_NAME = "iso-8859-1";
private static final String HASH_FUNCTION_NAME = "SHA-1";
// When called from the server, this is the server version. When called from
// the client, this is the client version.
String appVersion;
// When called from the server, this is the minimum client version supported.
// When called from the client, this is the minimum server version supported.
String hisMinimumVersion;
/**
* Creates an authentication handler. This class is shared between the client
* and server, which means the version number passed in are relative the
* caller.
*
* @param appVersion
* When called from the server, this is the server version. When
* called from the client, this is the client version.
* @param hisMinimumVersion
* When called from the server, this is the minimum client version
* supported. When called from the client, this is the minimum server
* version supported.
*/
public AuthenticationHandler(String appVersion, String hisMinimumVersion) {
this.appVersion = appVersion;
this.hisMinimumVersion = hisMinimumVersion;
}
/**
* Generates a reply that the client should send to an authentication challenge.
*
* @param password
* the password provided by the user
* @param challenge
* the challenge that was issued by the server
* @throws NoSuchAlgorithmException
* if the hash algorithm was not found
* @throws UnsupportedEncodingException
* if the string encoding is not supported
*/
public AuthenticationReply generateReplyToChallenge(String password, String challenge)
throws NoSuchAlgorithmException, UnsupportedEncodingException {
return new AuthenticationReply(computeChallengeResponse(password, challenge), appVersion);
}
/**
* Generates a hash of a server's challenge.
*
* @param password
* the password provided by the user
* @param challenge
* the challenge that was issued by the server
* @throws NoSuchAlgorithmException
* if the hash algorithm was not found
* @throws UnsupportedEncodingException
* if the string encoding is not supported
*/
private byte[] computeChallengeResponse(String password, String challenge) throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest md;
md = MessageDigest.getInstance(HASH_FUNCTION_NAME);
String response = password + challenge;
md.update(response.getBytes(ENCODING_NAME), 0, response.length());
return md.digest();
}
/**
* Generates a unique challenge that the client will have to respond to.
*
* @return
*/
public String generateServerChallenge() {
// Generate a large random number.
Random rnd = new Random();
long rndNum = rnd.nextLong();
// Also append the time to this challenge. This will slightly improve the
// chances that we do not pass the same challenge twice.
long time = Calendar.getInstance().getTimeInMillis();
return Long.toString(time) + Long.toString(rndNum);
}
/**
* Allows the server to authenticate the client.
*
* @throws AuthenticationException when there is a problem authenticating the user
* @throws IncompatibleClientException
*/
public void performAuthentication(TcpConnection con, String expectedPassword, String challenge) throws AuthenticationException, IncompatibleClientException {
// Send a challenge to the user.
try {
con.sendPacket(new AuthenticationReq(challenge, appVersion));
// Wait for the response.
LOGGER.info("Waiting for authentication reply");
AuthenticationReply packet = null;
try {
packet = (AuthenticationReply) con.readPacket();
LOGGER.info("Authentication reply received.");
} catch (SocketTimeoutException e) {
LOGGER.warning("Authentication failed. The client took too long to respond.");
throw new AuthenticationException();
}
String clientVersion = packet.getClientVersion();
LOGGER.warning("Authentication attempt: server version = " + appVersion
+ " - client version = " + clientVersion);
if (!isVersionCompatible(clientVersion)) {
throw new IncompatibleClientException(
"The Gmote client that is on your phone is out of date. Please update your client by going to the Android Market");
}
final byte[] challengeReply = packet.getChallengeReply();
// Determine what the expected response should be.
final byte[] expectedChallengeReply = computeChallengeResponse(expectedPassword, challenge);
if (!Arrays.equals(challengeReply, expectedChallengeReply)) {
throw new AuthenticationException();
}
} catch (IOException e) {
LOGGER.log(Level.SEVERE, e.getMessage(), e);
throw new AuthenticationException();
} catch (NoSuchAlgorithmException e) {
LOGGER.log(Level.SEVERE, e.getMessage(), e);
throw new AuthenticationException();
} catch (ClassNotFoundException e) {
LOGGER.log(Level.SEVERE, e.getMessage(), e);
throw new AuthenticationException();
} catch (ClassCastException e) {
LOGGER.log(Level.SEVERE, e.getMessage(), e);
throw new AuthenticationException();
}
}
public boolean isVersionCompatible(String hisCurrentVersion) {
int[] minVersion = convertVersion(hisMinimumVersion);
int[] hisVersion = convertVersion(hisCurrentVersion);
if (hisVersion[0] > minVersion[0]) {
return true;
} else if (hisVersion[0] < minVersion[0]) {
return false;
}
if (hisVersion[1] > minVersion[1]) {
return true;
} else if (hisVersion[1] < minVersion[1]) {
return false;
}
if (hisVersion[2] < minVersion[2]) {
return false;
}
return true;
}
/**
* Converts a string version number into ints.
*
* @param versionNumber
* a version number in the form 1.2.3 or 1.2
* @return an array of 3 integers representing the version number.
*/
private static int[] convertVersion(String versionNumber) {
String versionSplit[] = versionNumber.split("\\.");
int version[] = new int[3];
version[0] = Integer.parseInt(versionSplit[0]);
version[1] = Integer.parseInt(versionSplit[1]);
if (versionSplit.length == 3) {
// Some older version of the server only had two digits (ex: 1.2)
version[2] = Integer.parseInt(versionSplit[2]);
} else {
version[2] = 0;
}
return version;
}
}