//****************************************************************************************************** // OIDCSecurityProvider.cs - Gbtc // // Copyright © 2021, 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: // ---------------------------------------------------------------------------------------------------- // 08/05/2021 - C. Lackner // Generated original version of source code. // //****************************************************************************************************** using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Configuration; using System.Data; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; using System.Runtime.Caching; using System.Security; using System.Text; using System.Threading.Tasks; using GSF.Collections; using GSF.Configuration; using GSF.Data; using GSF.Diagnostics; using Newtonsoft.Json; using Newtonsoft.Json.Linq; // ReSharper disable UnusedAutoPropertyAccessor.Local #pragma warning disable 67 #pragma warning disable 169 namespace GSF.Security { /// /// Defines a JSON token response for the . /// internal class TokenResponse { #pragma warning disable IDE1006 // Naming Styles /// /// Access token. /// public string access_token { get; set; } /// /// ID token. /// public string id_token { get; set; } #pragma warning restore IDE1006 } /// /// Represents an that uses openID Connect /// /// /// Required config file entries (automatically added): /// /// /// /// ///
/// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// ]]> /// /// public class OIDCSecurityProvider : SecurityProviderBase { #region [ Members ] // Constants private const string ResponseType = "code"; // Response Type the SecurityProvider expects private const int NonceSlidingExpiration = 600; // Time in Seconds that a Nonce is valid for. This prevents replay attacks /// /// Defines the provider ID for the . /// public const int ProviderID = 3; // Fields private string m_clientRequestUri; #endregion #region [ Constructors ] /// /// Initializes a new instance of the class. /// /// Name that uniquely identifies the user. public OIDCSecurityProvider(string username) : base(username, false, false, false) { } #endregion #region [ Properties ] /// /// Gets the object containing information about the user. /// public new OIDCUserData UserData { get => base.UserData as OIDCUserData; protected set => base.UserData = value; } /// /// Gets last exception reported by the . /// public Exception LastException { get; set; } /// /// Gets or sets flag that determines if and should /// write to the database. Defaults to true. /// /// /// Setting this flag to false may be necessary in cases where a database has been setup to use authentication /// but does not include an "AccessLog" or "ErrorLog" table. /// public bool UseDatabaseLogging { get; set; } /// /// The ClienID used to identify this Application with the Authorization Server /// public string ClientID { get; set; } /// /// The Scope used to obtain UserInformation from the Authorization Server /// public string Scope { get; set; } /// /// A Nonce that has been verified manually and never expires. This is used for allowing Server-server logons. /// public string SelfVerifiedNonce { get; set; } /// /// The Endpoint used to redirect the User /// public string AuthorizationEndpoint { get; set; } /// /// The Endpoint to get the User Token /// public string TokenEndpoint { get; set; } /// /// The URI the User get's redirected to after signing in. /// public string RedirectURI { get; set; } /// /// The ClientSecret used to encrypt the user data /// public string ClientSecret { get; set; } /// /// The Claim used to get the Roles for the user /// public string RolesClaim { get; set; } /// /// Gets the flag that indicates whether the user /// needs to be redirected after the Authentication attempt. /// public override bool IsRedirectRequested => !string.IsNullOrEmpty(m_clientRequestUri); /// /// Gets the URI that user will be redirected to if is set. /// public override string RequestedRedirect { get { string val = m_clientRequestUri; m_clientRequestUri = ""; return val; } } /// /// Indicates if the Login Page should display detailed Debugging Information when OAuth Fails. /// public bool ShowDetailedError { get; set; } #endregion #region [ Methods ] /// /// Loads saved security provider settings from the config file if the property is set to true. /// /// has a value of null or empty string. public override void LoadSettings() { base.LoadSettings(); // Make sure default settings exist ConfigurationFile config = ConfigurationFile.Current; CategorizedSettingsElementCollection settings = config.Settings[SettingsCategory]; settings.Add(nameof(ClientID), "", "Defines the ClientID as required per OpenID Connect Standard."); settings.Add(nameof(Scope), "", "Defines the Scope as required per OpenID Connect Standard."); settings.Add(nameof(AuthorizationEndpoint), "", "Defines the Endpoint to redirect the user for Authorization."); settings.Add(nameof(RedirectURI), "", "Defines the URI the User get's redirected to after signing in."); settings.Add(nameof(ClientSecret), "", "Defines the Client Secret as required per OpenID Connect Standard for decrypting User Information."); settings.Add(nameof(TokenEndpoint), "", "Defines the Endpoint to use to grab the User Token."); settings.Add(nameof(RolesClaim), "roles", "Defines the claim used to identify the users roles."); settings.Add(nameof(SelfVerifiedNonce), "", "Defines a nonce that is verified manually."); settings.Add(nameof(ShowDetailedError), "false", "Indicates if the Login Page should display detailed Debuging Information when OAuth Fails."); ClientID = settings[nameof(ClientID)].ValueAs(ClientID); Scope = settings[nameof(Scope)].ValueAs(Scope); AuthorizationEndpoint = settings[nameof(AuthorizationEndpoint)].ValueAs(AuthorizationEndpoint); RedirectURI = settings[nameof(RedirectURI)].ValueAs(RedirectURI); ClientSecret = settings[nameof(ClientSecret)].ValueAs(ClientSecret); TokenEndpoint = settings[nameof(TokenEndpoint)].ValueAs(TokenEndpoint); RolesClaim = settings[nameof(RolesClaim)].ValueAs(RolesClaim); SelfVerifiedNonce = settings[nameof(SelfVerifiedNonce)].ValueAs(SelfVerifiedNonce); ShowDetailedError = settings[nameof(ShowDetailedError)].ValueAs(ShowDetailedError); } /// /// Not implemented by ; always returns false. /// /// true if is refreshed, otherwise false. public override bool RefreshData() => false; /// /// Authenticates the user. /// /// true if the user is authenticated, otherwise false. public override bool Authenticate() { // Reset authenticated state and failure reason AuthenticationFailureReason = null; IsUserAuthenticated = false; try { RequestToken(base.UserData.Username); if (UserData.Roles.Count == 0) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" has not been assigned any roles and therefore has no rights. Contact your administrator."; IsUserAuthenticated = false; } else { IsUserAuthenticated = !string.IsNullOrEmpty(UserData.Nonce); } // Log user authentication result LogAuthenticationAttempt(IsUserAuthenticated); } catch (Exception ex) { // Writing data will fail for read-only databases; // all we can do is track last exception in this case LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "Authenticate", "Failed to log authentication attempt to database.", "Database or AccessLog table may be read-only or inaccessible.", ex); } return IsUserAuthenticated; } /// /// Logs user authentication attempt. /// /// true if user authentication was successful, otherwise false. protected virtual void LogAuthenticationAttempt(bool loginSuccess) { if (UserData is null || string.IsNullOrWhiteSpace(UserData.Username)) return; string message = $"User \"{UserData.Username}\" login attempt {(loginSuccess ? "succeeded using OpenID Connect" : "failed")}."; EventLogEntryType entryType = loginSuccess ? EventLogEntryType.SuccessAudit : EventLogEntryType.FailureAudit; // Suffix authentication failure reason on failed logins if available if (!loginSuccess && !string.IsNullOrWhiteSpace(AuthenticationFailureReason)) message = string.Concat(message, " ", AuthenticationFailureReason); // Attempt to write success or failure to the event log try { Log.Publish(MessageLevel.Info, MessageFlags.SecurityMessage, "AuthenticationAttempt", message); LogEvent(ApplicationName, message, entryType, 1); } catch (Exception ex) { LogError(ex.Source, ex.ToString()); } // Attempt to write success or failure to the database - we allow caller to catch any possible exceptions here so that // database exceptions can be tracked separately (via LastException property) from other login exceptions, e.g., when // a read-only database is being used or current user only has read-only access to database. if (!string.IsNullOrWhiteSpace(SettingsCategory) && UseDatabaseLogging) { using AdoDataConnection connection = new(SettingsCategory); connection.ExecuteNonQuery("INSERT INTO AccessLog (UserName, AccessGranted) VALUES ({0}, {1})", UserData.Username, loginSuccess ? 1 : 0); } } /// /// Logs information about an encountered exception to the backend data store. /// /// Source of the exception. /// Detailed description of the exception. /// true if logging was successful, otherwise false. protected virtual bool LogError(string source, string message) { if (string.IsNullOrWhiteSpace(SettingsCategory) || string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(message)) return false; Log.Publish(MessageLevel.Error, MessageFlags.SecurityMessage, source, message); if (!UseDatabaseLogging) return false; try { using AdoDataConnection connection = new(SettingsCategory); connection.ExecuteNonQuery("INSERT INTO ErrorLog (Source, Message) VALUES ({0}, {1})", source, message); return true; } catch (Exception ex) { // Writing data will fail for read-only databases; // all we can do is track last exception in this case LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "LogErrorToDatabase", "Failed to log error to database.", "Database or ErrorLog table may be read-only or inaccessible.", ex); } return false; } /// /// Exchange Authorization Code for a Token /// /// The Authorization Code returned by the Auth Server private async Task GetTokenAsync(string code) { Dictionary postParams = new() { { "grant_type", "authorization_code" }, { "client_id", ClientID }, { "client_secret", ClientSecret }, { "code", code }, { "redirect_uri", RedirectURI } }; void ConfigureRequest(HttpRequestMessage request) { request.RequestUri = new Uri(TokenEndpoint); request.Method = HttpMethod.Post; request.Content = new FormUrlEncodedContent(postParams); } using (HttpRequestMessage request = new()) { ConfigureRequest(request); using HttpResponseMessage response = await Client.SendAsync(request); if (!response.IsSuccessStatusCode) return null; return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } } private JObject DecodeJWT(string token) { if (!token.Contains(".")) throw new InvalidExpressionException("A valid JWT token requires at least one '.'"); const int JOSEHeaderIndex = 0; const int PayloadIndex = 1; //const int SignatureIndex = 2; string[] splitToken = token.Split('.'); if (splitToken.Length <= PayloadIndex) throw new FormatException("JWT token has no payload"); string joseHeader = splitToken[JOSEHeaderIndex]; byte[] headerData = Convert.FromBase64String(joseHeader); joseHeader = Encoding.UTF8.GetString(headerData); JObject header = JObject.Parse(joseHeader); if (header.TryGetValue("enc", out _)) throw new FormatException("JWE Tokens are not supported"); // TODO: Implement signature verifications based on config file setting //if (SignatureIndex < splitToken.Length) //{ // // We are not validating signatures to allow openXDA to self-sign tokens // void ValidateToken(string _) { } // string signature = splitToken[SignatureIndex]; // ValidateToken(signature); //} string payload = splitToken[PayloadIndex]; byte[] payloadData = Convert.FromBase64String(payload); string payloadContent = Encoding.UTF8.GetString(payloadData); if (header.TryGetValue("cty", out JToken cty) && cty.ToString().ToLower() == "jwt") return DecodeJWT(payloadContent); return JObject.Parse(payloadContent); } /// /// Performs a translation of the default login page to a different endpoint. /// /// The URI of the login page specified in the AppSettings /// The URI originally requested. /// The URI requested by the client /// The Referrer as specified in the request header /// The URI to be redirected to public override string TranslateRedirect(string loginUrl, Uri uri, string encodedPath, string referrer) { Uri redirectURi = new(RedirectURI); if (redirectURi.AbsolutePath == uri.AbsolutePath && (uri.Query.Contains("code=") || uri.Query.Contains("error="))) { if (LastException is not null) return $"{loginUrl}?oidcError={WebUtility.UrlEncode(LastException.Message)}"; //This is a redirect issue and needs to display error page if (ShowDetailedError && uri.Query.Contains("error_description=")) return $"{loginUrl}?oidcError={WebUtility.UrlEncode(System.Web.HttpUtility.ParseQueryString(uri.Query).Get("error_description"))}"; return $"{loginUrl}?oidcError={WebUtility.UrlEncode("A Server Error Occured.")}"; } byte[] nonce = new byte[16]; Cryptography.Random.GetBytes(nonce); s_nonceCache.Add(BitConverter.ToString(nonce).Replace("-", ""), encodedPath, new CacheItemPolicy { SlidingExpiration = TimeSpan.FromSeconds(NonceSlidingExpiration) }); StringBuilder redirect = new(); redirect.Append(AuthorizationEndpoint); redirect.Append($"?response_type={ResponseType}"); redirect.Append($"&scope={Scope}"); redirect.Append($"&client_id={ClientID}"); redirect.Append($"&redirect_uri={RedirectURI}"); redirect.Append($"&nonce={BitConverter.ToString(nonce).Replace("-", "")}"); return redirect.ToString(); } /// /// Not implemented by ; always returns false. /// /// Answer to the user's security question. /// true if the password is reset, otherwise false. /// public override bool ResetPassword(string securityAnswer) => false; /// /// Not implemented by ; always returns false. /// /// User's current password. /// User's new password. /// true if the password is changed, otherwise false. public override bool ChangePassword(string oldPassword, string newPassword) => false; /// /// Not implemented by ; always returns false. /// public override bool CanRefreshData => false; /// /// Obtains the Token from the OIDC Server using a code. /// private void RequestToken(string code) { if (string.IsNullOrEmpty(code)) return; // Already have token for this user, do not re-initialize user data if (UserData?.Token is not null) return; m_clientRequestUri = ""; // Get user token TokenResponse token = GetTokenAsync(code).GetAwaiter().GetResult(); JObject tokenContent = DecodeJWT(token.id_token); // Translate UserDetails according to Token if (!tokenContent.TryGetValue("sub", out JToken sub)) throw new Exception("Token data was not derived from valid JWT: missing 'sub'"); if (!tokenContent.TryGetValue("nonce", out JToken nonce)) throw new Exception("Token data was not derived from valid JWT: missing 'nonce'"); // TODO: validate Nonce matches nonce send for this user if (!tokenContent.TryGetValue("name", out JToken userName)) throw new Exception("Token data was not derived from valid JWT: missing 'name'"); OIDCUserData userData = new(sub.ToString()) { Username = userName.ToString(), Nonce = nonce.ToString(), Token = token }; // Initialize user data. userData.Initialize(); if (tokenContent.TryGetValue("given_name", out JToken firstName)) userData.FirstName = firstName.ToString(); if (tokenContent.TryGetValue("family_name", out JToken lastName)) userData.LastName = lastName.ToString(); if (tokenContent.TryGetValue("phone_number", out JToken phoneNumber)) userData.PhoneNumber = phoneNumber.ToString(); if (tokenContent.TryGetValue("email", out JToken email)) userData.EmailAddress = email.ToString(); try { // Roles are obtained from a Claim userData.Roles = tokenContent.GetOrDefault(RolesClaim).ToString().Split(',').ToList(); } catch (Exception ex) { LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "DecodingTokenError", "Failed to decode Roles Claim.", "Failed to decode Claim for roles.", ex); throw new Exception("Failed to Decode Roles Claim: " + ex.Message, ex); } userData.IsDefined = true; userData.IsDisabled = false; userData.IsLockedOut = false; UserData = userData; if (s_nonceCache.Contains(nonce.ToString())) { string base64Path = WebUtility.UrlDecode((string)s_nonceCache.Get(nonce.ToString())); byte[] pathBytes = Convert.FromBase64String(base64Path); m_clientRequestUri = Encoding.UTF8.GetString(pathBytes); } } #endregion #region [ Static ] // Static Fields private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(OIDCSecurityProvider), MessageClass.Component); private static readonly MemoryCache s_nonceCache = new("OIDC-NonceCache"); private static readonly HttpClient Client = new(); #endregion } }