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
}
}