//******************************************************************************************************
// RadiusClient.cs - Gbtc
//
// Copyright © 2012, Grid Protection Alliance. All Rights Reserved.
//
// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
// the NOTICE file distributed with this work for additional information regarding copyright ownership.
// The GPA licenses this file to you under the MIT License (MIT), 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.opensource.org/licenses/MIT
//
// Unless agreed to in writing, the subject software distributed under the License is distributed on an
// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
// License for the specific language governing permissions and limitations.
//
// Code Modification History:
// ----------------------------------------------------------------------------------------------------
// 11/26/2010 - Pinal C. Patel
// Generated original version of source code.
// 12/13/2012 - Starlynn Danyelle Gilliam
// Modified Header.
// 04/24/2014 - J. Ritchie Carroll
// Code clean-up.
//
//******************************************************************************************************
using System;
using System.Collections.Generic;
using System.Threading;
using GSF.Parsing;
namespace GSF.Communication.Radius
{
///
/// Represents a RADIUS communication client.
///
public class RadiusClient : IDisposable
{
#region [ Members ]
// Constants
///
/// Default port of the RADIUS server.
///
public const int DefaultServerPort = 1812;
///
/// Default text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 1 (ensuring that the user account is in
/// the "New Pin" mode) of creating a new pin was successful.
///
public const string DefaultNewPinModeMessage1 = "Enter a new PIN";
///
/// Default text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 2 (new pin is accepted in attempt #1)
/// of creating a new pin was successful.
///
public const string DefaultNewPinModeMessage2 = "Please re-enter new PIN";
///
/// Default text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 3 (new pin is accepted in attempts #2)
/// of creating a new pin was successful.
///
public const string DefaultNewPinModeMessage3 = "PIN Accepted";
///
/// Default text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not a user account is in the "Next Token" mode.
///
public const string DefaultNextTokenModeMessage = "Wait for token to change";
// Fields
private short m_requestAttempts;
private int m_reponseTimeout;
private string m_sharedSecret;
private string m_newPinModeMessage1;
private string m_newPinModeMessage2;
private string m_newPinModeMessage3;
private string m_nextTokenModeMessage;
private bool m_disposed;
private byte[] m_responseBytes;
private readonly UdpClient m_udpClient;
#endregion
#region [ Constructors ]
///
/// Creates an instance of RADIUS client for sending request to a RADIUS server.
///
/// Name or address of the RADIUS server.
/// Shared secret used for encryption and authentication.
public RadiusClient(string serverName, string sharedSecret) : this(serverName, DefaultServerPort, sharedSecret)
{
}
///
/// Creates an instance of RADIUS client for sending request to a RADIUS server.
///
/// Name or address of the RADIUS server.
/// Port number of the RADIUS server.
/// Shared secret used for encryption and authentication.
///
public RadiusClient(string serverName, int serverPort, string sharedSecret)
{
SharedSecret = sharedSecret;
RequestAttempts = 1;
ReponseTimeout = 15000;
NewPinModeMessage1 = DefaultNewPinModeMessage1;
NewPinModeMessage2 = DefaultNewPinModeMessage2;
NewPinModeMessage3 = DefaultNewPinModeMessage3;
NextTokenModeMessage = DefaultNextTokenModeMessage;
m_udpClient = new UdpClient($"Server={serverName}; RemotePort={serverPort}; LocalPort=0");
m_udpClient.ReceiveDataComplete += m_udpClient_ReceivedData;
m_udpClient.Connect(); // Start the connection cycle.
}
#endregion
#region [ Properties ]
///
/// Gets or sets the name or address of the RADIUS server.
///
///
/// Name or address of RADIUS server.
public string ServerName
{
get => m_udpClient.ConnectionString.ParseKeyValuePairs()["server"];
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
{
Dictionary parts = m_udpClient.ConnectionString.ParseKeyValuePairs();
parts["server"] = value;
m_udpClient.ConnectionString = parts.JoinKeyValuePairs();
}
else
{
throw new ArgumentNullException(nameof(value));
}
}
}
///
/// Gets or sets the port number of the RADIUS server.
///
///
/// Port number of the RADIUS server.
public int ServerPort
{
get => Convert.ToInt32(m_udpClient.ConnectionString.ParseKeyValuePairs()["remotePort"]);
set
{
CheckDisposed();
if (value >= 0 && value <= 65535)
{
Dictionary parts = m_udpClient.ConnectionString.ParseKeyValuePairs();
parts["remotePort"] = value.ToString();
m_udpClient.ConnectionString = parts.JoinKeyValuePairs();
}
else
{
throw new ArgumentOutOfRangeException(nameof(value), "Value must be between 0 and 65535.");
}
}
}
///
/// Gets or sets the number of time a request is to sent to the server until a valid response is received.
///
///
/// Number of time a request is to sent to the server until a valid response is received.
public short RequestAttempts
{
get => m_requestAttempts;
set
{
CheckDisposed();
if (value >= 1 && value <= 10)
m_requestAttempts = value;
else
throw new ArgumentOutOfRangeException(nameof(value), "Value must be between 1 and 10.");
}
}
///
/// Gets or sets the time (in milliseconds) to wait for a response from server after sending a request.
///
///
/// Time (in milliseconds) to wait for a response from server after sending a request.
public int ReponseTimeout
{
get => m_reponseTimeout;
set
{
CheckDisposed();
if (value >= 1000 && value <= 60000)
m_reponseTimeout = value;
else
throw new ArgumentOutOfRangeException(nameof(value), "Value must be between 1000 and 60000.");
}
}
///
/// Gets or sets the shared secret used between the client and server for encryption and authentication.
///
///
/// Shared secret used between the client and server for encryption and authentication.
public string SharedSecret
{
get => m_sharedSecret;
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
m_sharedSecret = value;
else
throw new ArgumentNullException(nameof(value));
}
}
///
/// Gets or sets the text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 1 (ensuring that the user account is in the
/// "New Pin" mode) of creating a new pin was successful.
///
///
/// Text for "New Pin" mode's first message.
public string NewPinModeMessage1
{
get => m_newPinModeMessage1;
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
m_newPinModeMessage1 = value;
else
throw new ArgumentNullException(nameof(value));
}
}
///
/// Gets or sets the text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 2 (new pin is accepted in attempt #1) of creating
/// a new pin was successful.
///
///
/// Text for "New Pin" mode's second message.
public string NewPinModeMessage2
{
get => m_newPinModeMessage2;
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
m_newPinModeMessage2 = value;
else
throw new ArgumentNullException(nameof(value));
}
}
///
/// Gets or sets the text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not Step 3 (new pin is accepted in attempts #2) of creating
/// a new pin was successful.
///
///
/// Text for "New Pin" mode's third message.
public string NewPinModeMessage3
{
get => m_newPinModeMessage3;
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
m_newPinModeMessage3 = value;
else
throw new ArgumentNullException(nameof(value));
}
}
///
/// Gets or sets the text for comparing with the text of ReplyMessage attribute in an AccessChallenge
/// server response to determine whether or not a user account is in the "Next Token" mode.
///
///
/// Text for "Next Token" mode.
public string NextTokenModeMessage
{
get => m_nextTokenModeMessage;
set
{
CheckDisposed();
if (!string.IsNullOrEmpty(value))
m_nextTokenModeMessage = value;
else
throw new ArgumentNullException(nameof(value));
}
}
#endregion
#region [ Methods ]
///
/// Send a request to the server and waits for a response back.
///
/// Request to be sent to the server.
/// Response packet if a valid response is received from the server; otherwise Nothing.
public RadiusPacket ProcessRequest(RadiusPacket request)
{
CheckDisposed();
// We wait indefinitely for the connection to establish. But since this is UDP, the connection
// will always be successful (locally we're binding to any available UDP port).
if (m_udpClient.CurrentState != ClientState.Connected)
return null;
RadiusPacket response = null;
// We have a UDP socket we can use for exchanging packets.
for (int i = 1; i <= m_requestAttempts; i++)
{
m_responseBytes = null;
m_udpClient.Send(request.BinaryImage());
DateTime stopTime = DateTime.UtcNow.AddMilliseconds(m_reponseTimeout);
while (true)
{
Thread.Sleep(1);
// Stay in the loop until:
// 1) We receive a response OR
// 2) We exceed the response timeout duration
if (m_responseBytes != null || DateTime.UtcNow > stopTime)
break;
}
if (m_responseBytes != null)
{
// The server sent a response.
response = new RadiusPacket(m_responseBytes, 0, m_responseBytes.Length);
if (response.Identifier == request.Identifier && response.Authenticator.CompareTo(RadiusPacket.CreateResponseAuthenticator(m_sharedSecret, request, response)) == 0)
break;
// The response failed the verification, so we'll silently discard it.
response = null;
}
}
return response;
}
///
/// Create a new pin for the user.
///
/// Name of the user.
/// Current token of the user.
/// New pin of the user.
/// True if a new pin is created for the user successfully; otherwise False.
/// NOTE: This method is specific to RSA RADIUS implementation.
public bool CreateNewPin(string username, string token, string pin)
{
CheckDisposed();
if (string.IsNullOrEmpty(pin))
throw new ArgumentNullException(nameof(pin));
// Step 1: Send username and token for password, receive a challenge response with reply
// message worded "Enter a new PIN". [Verification]
// Step 2: Send username and new ping for password, receive a challenge response with reply
// message worded "Please re-enter. [Attempt #1]
// Step 3: Send username and new ping for password, receive a challenge response with reply
// message worded "PIN Accepted". [Attempt #2]
RadiusPacket response = Authenticate(username, token);
if (!IsUserInNewPinMode(response))
return false;
// User account is really in "New Pin" mode.
response = Authenticate(username, pin, response.GetAttributeValue(AttributeType.State));
byte[] reply = response.GetAttributeValue(AttributeType.ReplyMessage);
if (!RadiusPacket.Encoding.GetString(reply, 0, reply.Length).ToLower().Contains(m_newPinModeMessage2.ToLower()))
return false; // New pin not accepted in attempt #1.
response = Authenticate(username, pin, response.GetAttributeValue(AttributeType.State));
reply = response.GetAttributeValue(AttributeType.ReplyMessage);
return RadiusPacket.Encoding.GetString(reply, 0, reply.Length).ToLower().Contains(m_newPinModeMessage3.ToLower());
}
///
/// Authenticates the username and password against the RADIUS server.
///
/// Username to be authenticated.
/// Password to be authenticated.
/// Response packet received from the server for the authentication request.
///
///
/// The type of response packet (if any) will be one of the following:
///
/// - AccessAccept: If the authentication is successful.
/// - AccessReject: If the authentication is not successful.
/// - AccessChallenge: If the server need more information from the user.
///
///
///
/// When an AccessChallenge response packet is received from the server, it contains a State attribute
/// that must be included in the AccessRequest packet that is being sent in response to the AccessChallenge
/// response. So if this method returns an AccessChallenge packet, then this method is to be called again
/// with the requested information (from ReplyMessage attribute) in the password field and the value State
/// attribute.
///
///
public RadiusPacket Authenticate(string username, string password) => Authenticate(username, password, null);
///
/// Authenticates the username and password against the RADIUS server.
///
/// Username to be authenticated.
/// Password to be authenticated.
/// State value from a previous challenge response.
/// Response packet received from the server for the authentication request.
///
///
/// The type of response packet (if any) will be one of the following:
///
/// - AccessAccept: If the authentication is successful.
/// - AccessReject: If the authentication is not successful.
/// - AccessChallenge: If the server need more information from the user.
///
///
///
/// When an AccessChallenge response packet is received from the server, it contains a State attribute
/// that must be included in the AccessRequest packet that is being sent in response to the AccessChallenge
/// response. So if this method returns an AccessChallenge packet, then this method is to be called again
/// with the requested information (from ReplyMessage attribute) in the password field and the value State
/// attribute.
///
///
public RadiusPacket Authenticate(string username, string password, byte[] state)
{
CheckDisposed();
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
throw new ArgumentException("Username and Password cannot be null.");
RadiusPacket request = new RadiusPacket(PacketType.AccessRequest);
byte[] authenticator = RadiusPacket.CreateRequestAuthenticator(m_sharedSecret);
request.Authenticator = authenticator;
request.Attributes.Add(new RadiusPacketAttribute(AttributeType.UserName, username));
request.Attributes.Add(new RadiusPacketAttribute(AttributeType.UserPassword, RadiusPacket.EncryptPassword(password, m_sharedSecret, authenticator)));
// State attribute is used when responding to a AccessChallenge response.
if (state != null)
request.Attributes.Add(new RadiusPacketAttribute(AttributeType.State, state));
return ProcessRequest(request);
}
///
/// Determines whether or not the response indicates that the user account is in "New Pin" mode.
///
/// Response packet sent by the server.
/// True if the user account is in "New Pin" mode; otherwise False.
///
/// A user's account can be in the "New Pin" mode when set on the server.
/// NOTE: This method is specific to RSA RADIUS implementation.
///
public bool IsUserInNewPinMode(RadiusPacket response)
{
CheckDisposed();
if (response == null)
throw new ArgumentNullException(nameof(response));
byte[] messageBytes = response.GetAttributeValue(AttributeType.ReplyMessage);
if (messageBytes == null)
throw new ArgumentException("ReplyMessage attribute is not present", nameof(response));
// Unfortunately, the only way of determining whether or not a user account is in the
// "New Pin" mode is from the text present in the ReplyMessage attribute of the
// AccessChallenge response from server.
string messageString = RadiusPacket.Encoding.GetString(messageBytes, 0, messageBytes.Length);
return messageString.ToLower().Contains(m_newPinModeMessage1.ToLower());
}
///
/// Determines whether or not the response indicates that the user account is in "Next Token" mode.
///
/// Response packet sent by the server.
/// True if the user account is in "Next Token" mode; otherwise False.
///
///
/// A user's account can enter the "Next Token" mode after the user enters incorrect passwords for a few
/// times (3 times by default) and then enters the correct password. Note that repeatedly entering
/// incorrect passwords will disable the user account.
///
/// NOTE: This method is specific to RSA RADIUS implementation.
///
public bool IsUserInNextTokenMode(RadiusPacket response)
{
CheckDisposed();
if (response == null)
throw new ArgumentNullException(nameof(response));
byte[] messageBytes = response.GetAttributeValue(AttributeType.ReplyMessage);
if (messageBytes == null)
throw new ArgumentException("ReplyMessage attribute is not present", nameof(response));
// Unfortunately, the only way of determining whether or not a user account is in the
// "Next Token" mode is from the text present in the ReplyMessage attribute of the
// AccessChallenge response from server.
string messageString = RadiusPacket.Encoding.GetString(messageBytes, 0, messageBytes.Length);
return messageString.ToLower().Contains(m_nextTokenModeMessage.ToLower());
}
///
/// Releases the used resources.
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///
/// Helper method to check whether or not the object instance has been disposed.
///
/// This method is to be called before performing any operation.
protected void CheckDisposed()
{
if (m_disposed)
throw new ObjectDisposedException(GetType().Name);
}
///
/// Releases the used resources.
///
protected virtual void Dispose(bool disposing)
{
if (!m_disposed)
{
if (disposing)
{
if (m_udpClient != null)
{
m_udpClient.ReceiveDataComplete -= m_udpClient_ReceivedData;
m_udpClient.Dispose();
}
}
}
m_disposed = true;
}
private void m_udpClient_ReceivedData(object sender, EventArgs e) => m_responseBytes = e.Argument1;
#endregion
}
}