using System; using System.Collections.Generic; using System.Drawing; using System.IO; using System.Net; using System.Security.Authentication; using System.Security.Cryptography; using System.Text; using System.Collections.Specialized; using System.Threading; using FastReport.Cloud.FastReport; using FastReport.Utils; using System.Threading.Tasks; namespace FastReport.Auth { /// /// Service for working with auth in the Fast Report. /// public class AuthService { #region Private Fields private string code; private string code_verifier; private string nonce; private string scopes; private string session; private string state; private string redirectUri; private string lastAuthHost; #endregion Private Fields #region Public Properties /// /// Instance of default Service. /// public static AuthService Instance { get; } = new AuthService(); /// /// Gets or sets indicator to enable or disable personalisation service /// public bool IsEnable { get; set; } = true; /// /// Setting of the service. /// public AppSettings Settings { get; } = new AppSettings(); /// /// User of the service. /// public AppUser User { get; } = new AppUser(); #endregion Public Properties #region Public Methods /// /// If FastReport.config contains information about custom server and api-key to it, /// this method will reset inner instance with that data. Otherwise default server /// will be set. /// /// /// The method creates an sign in link. /// /// public string GenerateSignInUri(string redirectURI) { try { this.nonce = NewRandomString(10); this.state = NewRandomString(64); this.code_verifier = NewRandomString(128); string codeChallenge = Sha256Url(code_verifier); StringBuilder sb = new StringBuilder(1024); sb.Append(Settings.Host + Settings.AuthorizationEndpoint).Append('?') .Append("response_type=").Append(Uri.EscapeDataString(Settings.ResponseType)) .Append('&') .Append("client_id=").Append(Uri.EscapeDataString(Settings.ClientId)) .Append('&') .Append("nonce=").Append(Uri.EscapeDataString(this.nonce)) .Append('&') .Append("redirect_uri=").Append(Uri.EscapeDataString(redirectURI)) .Append('&') .Append("scope=").Append(Uri.EscapeDataString(Settings.Scopes)) //.Append('&') //.Append("response_mode=").Append(Uri.EscapeDataString(Settings.ResponseMode)) .Append('&') .Append("code_challenge_method=").Append(Uri.EscapeDataString(Settings.CodeChallengeMethod)) .Append('&') .Append("code_challenge=").Append(Uri.EscapeDataString(codeChallenge)) .Append('&') .Append("state=").Append(Uri.EscapeDataString(this.state)); ; return sb.ToString(); } catch (Exception e) { throw new AuthenticationException("Error generating sign in uri, maybe one of the path parameters is null.", e); } } /// /// The method creates an sign out link. /// /// public string GenerateSignOutUri(string redirectURI) { try { StringBuilder sb = new StringBuilder(1024); sb.Append(GetAuthHost() + Settings.EndSessionEndpoint).Append('?') .Append("id_token_hint=") .Append(Uri.EscapeDataString(User.IdToken)) .Append("&post_logout_redirect_uri=") .Append(Uri.EscapeDataString(redirectURI)); return sb.ToString(); } catch (Exception e) { throw new AuthenticationException("Error generating sign out uri, maybe one of the path parameters is null.", e); } } /// /// Returns true, if user has offline_access scope and refresh_token is not null /// public bool CanRefresh { get { return !String.IsNullOrEmpty(User.RefreshToken) && (User.Scopes == null || User.Scopes != null && Contains(User.Scopes, "offline_access")); } } /// /// If possible, the method updates the user credentials. /// /// True if success public bool Refresh() { try { if (CanRefresh) { var request = HttpWebRequest.Create(new Uri(GetAuthHost() + Settings.TokenEndpoint)); request.Method = "POST"; request.ContentType = "application/x-www-form-urlencoded"; using (Stream requestStream = request.GetRequestStream()) { byte[] bytes = Encoding.UTF8.GetBytes(GenerateTokenRequestBodyByRefresh()); requestStream.Write(bytes, 0, bytes.Length); } using (var response = request.GetResponse()) { using (Stream responseStream = response.GetResponseStream()) { using (TextReader tr = new StreamReader(responseStream, Encoding.UTF8)) { var result = tr.ReadToEnd(); SaveTokens(result); ValidateTokens(); ParseTokens(); } } } return true; } } catch (Exception ex) { User.RefreshToken = null; } return false; } /// /// The method resets auth, without sign out process. /// public void Reset() { User.Reset(); } /// /// The method shows sign in form and auth the user. /// public async Task SignIn(CancellationToken token = default) { NameValueCollection queryString; using (var authServer = new TCPServerListener()) { redirectUri = authServer.RedirectURL; redirectUri = redirectUri.Remove(redirectUri.Length - 1); var uri = GenerateSignInUri(redirectUri + Settings.RedirectSignInUri); authServer.Open(); ProcessHelper.StartProcess(uri); var context = await authServer.WaitConnectAsync(token).ConfigureAwait(false); if (context is null) { return; } var response = context.Response; var request = context.Request; response.Redirect(Settings.Host + Settings.RedirectSignInUri); response.OutputStream.Close(); lastAuthHost = Settings.Host; queryString = request.QueryString; } SignInCalback(queryString); } /// /// The method shows sign out form and resets the user credentials. /// public async Task SignOut(CancellationToken token = default) { using (var authServer = new TCPServerListener()) { redirectUri = authServer.RedirectURL; redirectUri = redirectUri.Remove(redirectUri.Length - 1); string uri = GenerateSignOutUri(redirectUri + Settings.RedirectSignOutUri); authServer.Open(); ProcessHelper.StartProcess(uri); var context = await authServer.WaitConnectAsync(token).ConfigureAwait(false); if (context is null) { return; } var response = context.Response; response.Redirect(GetAuthHost() + Settings.RedirectSignInUri); response.OutputStream.Close(); } User.Reset(); } #endregion Public Methods #region Internal Methods internal static string NewRandomString(int v) { const string chars = "abcdefghijklmnopqrstuvwxyz1234567890"; Random r = new Random(); StringBuilder sb = new StringBuilder(v); for (int i = 0; i < v; i++) { sb.Append(chars[r.Next(chars.Length)]); } return sb.ToString(); } #endregion Internal Methods #region Private Methods private string GetAuthHost() { return string.IsNullOrEmpty(lastAuthHost) ? Settings.Host : lastAuthHost; } private void SignInCalback(NameValueCollection queryString) { // Checks for errors. if (HasError(queryString)) return; Process(queryString); SignInPart2SecondRequest(); } private bool HasError(NameValueCollection query) { var error = query.Get("error"); if (error != null) { if (error == "access_denied") return true; else throw new AuthenticationException(error); //output(String.Format("OAuth authorization error: {0}.", error)); } return false; } private static string Base64UrlToBase64(string base64url) { string base64 = base64url.Replace('-', '+').Replace('_', '/'); if (base64.Length % 4 != 0) { base64 += new string('=', 4 - base64.Length % 4); } return base64; } private static string ConvertToString(object v, string defaultValue) { if (v != null) return v.ToString(); return defaultValue; } private static string Sha256Url(string input) { if (String.IsNullOrEmpty(input)) return string.Empty; using (var sha = SHA256.Create()) { var bytes = Encoding.UTF8.GetBytes(input); var hash = sha.ComputeHash(bytes); return Convert.ToBase64String(hash).Replace('+', '-').Replace('/', '_').Replace("=", ""); } } private static bool Contains(IEnumerable scopes, string value) { foreach (string scope in scopes) { if (scope == value) return true; } return false; } private byte[] Download(string url) { using (MemoryStream memoryStream = new MemoryStream()) { #if MONO ServicePointManager.Expect100Continue = true; ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; #endif try { // send second request var request = HttpWebRequest.Create(new Uri(url)); request.Method = "GET"; using (var response = request.GetResponse()) { using (Stream responseStream = response.GetResponseStream()) { responseStream.CopyTo(memoryStream); } } } catch { } return memoryStream.ToArray(); } } private string GenerateTokenRequestBodyByCode(string redirectUri) { try { StringBuilder sb = new StringBuilder(1024); sb .Append("grant_type=authorization_code") //.Append(Uri.EscapeDataString(Settings.GrandType)) .Append("&client_id=").Append(Uri.EscapeDataString(Settings.ClientId)) .Append("&scope=").Append(Uri.EscapeDataString(this.scopes)) .Append("&redirect_uri=").Append(Uri.EscapeDataString(redirectUri)) .Append("&code=").Append(Uri.EscapeDataString(this.code)) .Append("&code_verifier=").Append(Uri.EscapeDataString(this.code_verifier)) .Append("&client_secret=").Append(Uri.EscapeDataString(Settings.ClientSecret)); return sb.ToString(); } catch (Exception e) { throw new AuthenticationException("Error generating token request body, maybe one of the path parameters is null.", e); } } private string GenerateTokenRequestBodyByRefresh() { try { StringBuilder sb = new StringBuilder(1024); sb .Append("grant_type=refresh_token") //.Append(Uri.EscapeDataString(Settings.GrandType)) .Append("&client_id=").Append(Uri.EscapeDataString(Settings.ClientId)) .Append("&refresh_token=").Append(Uri.EscapeDataString(User.RefreshToken)) .Append("&client_secret=").Append(Uri.EscapeDataString(Settings.ClientSecret)); ; if (User.Scopes != null && User.Scopes.Length > 0) { sb.Append("&scope=").Append(Uri.EscapeDataString(String.Join(" ", User.Scopes))); } return sb.ToString(); } catch (Exception e) { throw new AuthenticationException("Error generating token request body, maybe one of the path parameters is null.", e); } } private string Gravatar(string email) { MD5 md5Hasher = MD5.Create(); byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(email)); StringBuilder sBuilder = new StringBuilder("https://www.gravatar.com/avatar/"); for (int i = 0; i < data.Length; i++) { sBuilder.Append(data[i].ToString("x2")); } sBuilder.Append("?s=150"); return sBuilder.ToString(); } /// /// Do not make this method public, use refresh token for save-load.
/// You need only refresh token () to get a new token set.
/// This method is used to save time for starting a designer. ///
internal void ParseTokens(bool isProgramStart = false) { try { if (!String.IsNullOrEmpty(User.IdToken)) { string[] token = User.IdToken.Split('.'); string payload = token[1]; JsonBase json = JsonBase.FromString( Encoding.UTF8.GetString( Convert.FromBase64String( Base64UrlToBase64(payload) ) ) ); User.Subject = ConvertToString(json["sub"], ""); User.Email = ConvertToString(json["email"], ""); User.Username = ConvertToString(json["preferred_username"], User.Email); User.FullName = ConvertToString(json["name"], ""); if ((ConvertToString(json["nonce"], nonce) != nonce) && !isProgramStart) { throw new AuthenticationException("Nonce check error, token is not valid."); } try { var url = Gravatar(User.Email); var ms = Download(url); User.Avatar = ImageHelper.Load(ms); } catch (Exception e) { User.Avatar = null; } } } catch (AuthenticationException e) { User.IdToken = ""; throw e; } catch (Exception e) { User.IdToken = ""; throw new AuthenticationException("Identity token parse error!", e); } try { if (!String.IsNullOrEmpty(User.Token)) { string[] token = User.Token.Split('.'); string payload = token[1]; JsonBase json = JsonBase.FromString( Encoding.UTF8.GetString( Convert.FromBase64String( Base64UrlToBase64(payload) ) ) ); double nbf = Convert.ToDouble(json["nbf"]); double exp = Convert.ToDouble(json["exp"]); double time = exp - nbf; User.ExpiresIn = new DateTime(1970, 1, 1, 0, 0, 0, 0).AddSeconds(exp).ToLocalTime(); User.ExpiresInternal = new DateTime(1970, 1, 1, 0, 0, 0, 0).AddSeconds(nbf + time * 0.95).ToLocalTime(); JsonBase scopes = json["scope"] as JsonBase; if (scopes != null && scopes.IsArray) { List allowedScopes = new List(); for (int i = 0; i < scopes.Count; i++) { allowedScopes.Add(scopes[i].ToString()); } User.Scopes = allowedScopes.ToArray(); } } } catch (AuthenticationException e) { User.Token = ""; throw e; } catch (Exception e) { User.Token = ""; throw new AuthenticationException("Access token parse error!", e); } } private void Process(NameValueCollection query) { foreach (var key in query.AllKeys) { var value = query[key]; switch (key.ToLower()) { case "code": this.code = value; break; case "scope": scopes = value; break; case "state": if (value != this.state) throw new Exception("State is not valid"); break; case "session_state": this.session = value; break; } } } private void SaveTokens(string result) { JsonBase json = JsonBase.FromString(result); if (json.ContainsKey("expires_in")) { var expiresIn = Convert.ToSingle(json["expires_in"]); User.ExpiresIn = DateTime.Now.AddSeconds(expiresIn); User.ExpiresInternal = DateTime.Now.AddSeconds(expiresIn * 0.95); } else { // if no expires_in value, then default token lifetime User.ExpiresIn = DateTime.Now.AddMinutes(5); User.ExpiresInternal = User.ExpiresIn; } if (!json.ContainsKey("id_token")) { throw new AuthenticationException("No id token provided in server response."); } if (!json.ContainsKey("access_token")) { throw new AuthenticationException("No access token provided in server response."); } if (!json.ContainsKey("token_type")) { throw new AuthenticationException("No token type provided in server response."); } User.IdToken = json.ReadString("id_token"); User.Token = json.ReadString("access_token"); User.TokenType = json.ReadString("token_type"); User.RefreshToken = json.ReadString("refresh_token"); } private void SignInPart2SecondRequest() { var request = HttpWebRequest.Create(new Uri(Settings.Host + Settings.TokenEndpoint)); request.Method = "POST"; request.ContentType = "application/x-www-form-urlencoded"; using (Stream requestStream = request.GetRequestStream()) { string requestBody = GenerateTokenRequestBodyByCode(redirectUri + Settings.RedirectSignInUri); byte[] bytes = Encoding.UTF8.GetBytes(requestBody); requestStream.Write(bytes, 0, bytes.Length); } using (var response = request.GetResponse()) { using (Stream responseStream = response.GetResponseStream()) { using (TextReader tr = new StreamReader(responseStream, Encoding.UTF8)) { var result = tr.ReadToEnd(); SaveTokens(result); ValidateTokens(); ParseTokens(); } } } } private void ValidateTokens() { // External library for validation signature on tokens User.IsValid = false; } #endregion Private Methods #region Public Classes /// /// Class for store appsettings, by default appsettings is hardcoded. /// public class AppSettings { public AppSettings() { Host = Res.Get("Forms,AccountWindow,AuthServer"); } private string host; #region Public Properties /// /// Authorization Endpoint from the OAuth2 specification. /// public string AuthorizationEndpoint { get; set; } = "/connect/authorize"; /// /// Host for callback requests. /// public string CallbackHost { get; set; } = "https://id.fast-report.com"; /// /// Client identifier or client name from the OAuth2 specification. /// public string ClientId { get; set; } = "FastReport.Net.Designer"; /// /// Client secret or client name from the OAuth2 specification. /// public string ClientSecret { get; set; } = "91d18a32-1630-66d5-7f43-05d6e2caf02f"; /// /// Code challenge method from the OAuth2 specification. /// public string CodeChallengeMethod { get; set; } = "S256"; /// /// EndSession Endpoint from the OAuth2 specification. /// public string EndSessionEndpoint { get; set; } = "/connect/endsession"; /// /// Host for sign in requests /// public string Host { get => HttpMessages.Idn.GetAscii(host); set => host = value; } /// /// JSON Web Key Set Endpoint from the OAuth2 specification. /// public string JwksEndpoint { get; set; } = "/.well-known/openid-configuration/jwks"; /// /// Error result /// public string RedirectError { get; set; } = "/home/error"; /// /// Redirent sign in link for this application. /// public string RedirectSignInUri { get; set; } = "/native/sign-in"; /// /// Redirent sign out link for this application. /// public string RedirectSignOutUri { get; set; } = "/native/sign-out"; /// /// Success result /// public string RedirectSuccess { get; set; } = "/home/success"; /// /// Type of the reponse from the OAuth2 specification. /// public string ResponseType { get; set; } = "code"; /// /// Scopes for the request from the OAuth2 specification, splited by space. /// public string Scopes { get; set; } = "openid email profile offline_access fr.cloud.role"; /// /// Token Endpoint from the OAuth2 specification. /// public string TokenEndpoint { get; set; } = "/connect/token"; #endregion Public Properties } public class AppUser { #region Private Fields private Image avatar; private Image defaultAvatar; #endregion Private Fields #region Public Properties /// /// Avatar of the user, by default is 150x150 picture. /// public Image Avatar { get { return avatar; } set { if (avatar != null) avatar.Dispose(); avatar = value; } } /// /// Returns the display avatar of the user, cannot return null /// /// public Image DisplayAvatar { get { if (avatar != null) return avatar; if (defaultAvatar == null) defaultAvatar = ResourceLoader.GetBitmap("defaultAvatar.jpg"); return defaultAvatar; } } /// /// Returns the display email of the user, cannot return null /// /// public string DisplayEmail { get { if (Email == null) return ""; return Email; } } /// /// Returns the display name of the user, cannot return null /// /// public string DisplayName { get { if (String.IsNullOrEmpty(FullName)) { if (String.IsNullOrEmpty(Username)) { if (String.IsNullOrEmpty(Subject)) { return ""; } return Subject; } return Username; } return FullName; } } /// /// Email of the user. /// public string Email { get; set; } /// /// Local time when the token will go out. /// public DateTime ExpiresIn { get; set; } /// /// Full name of the user. /// public string FullName { get; set; } /// /// Returns true if user is authenticated. /// public bool IsAuthenticated { get { return !String.IsNullOrEmpty(IdToken) && !String.IsNullOrEmpty(Token); } } internal bool IsAuthentificatedAndActive { get { return IsAuthenticated && !IsExpired || !string.IsNullOrEmpty(ApiKey); } } /// /// Returns true if token is expired and is need to referesh /// public bool IsExpired { get { return ExpiresInternal < DateTime.Now; } } /// /// Indicates that token is check by external method, see for details. /// public bool IsValid { get; set; } /// /// List of allowed scopes. /// public string[] Scopes { get; set; } /// /// Identifier of the user. /// public string Subject { get; set; } /// /// Type of token for resource request header, e.g. Bearer. /// public string TokenType { get; set; } /// /// Preferred username of the user. /// public string Username { get; set; } /// /// User's api key. /// public string ApiKey { get; set; } #endregion Public Properties #region Internal Properties /// /// Local time when the token needs to be updated. /// internal DateTime ExpiresInternal { get; set; } internal string IdToken { get; set; } internal string RefreshToken { get; set; } internal string Token { get; set; } #endregion Internal Properties #region Public Methods /// /// Reset the values /// public void Reset() { Avatar = null; Email = null; ExpiresIn = DateTime.MinValue; FullName = null; IdToken = null; IsValid = false; RefreshToken = null; Scopes = null; Subject = null; Token = null; TokenType = null; Username = null; } #endregion Public Methods } #endregion Public Classes } }