287 lines
10 KiB
C#
287 lines
10 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Xml;
|
|
using Cinema.Webshare;
|
|
|
|
namespace CinemaLib.Webshare;
|
|
|
|
/// <summary>
|
|
/// Webshare user authentication context.
|
|
/// </summary>
|
|
public sealed class Session
|
|
{
|
|
internal const string TokenKeyName = "wst";
|
|
|
|
private static readonly Uri WebshareApiUri = LinkGenerator.WebshareApiUri;
|
|
private static readonly TimeSpan InitialLoginBackoff = new TimeSpan(4 * TimeSpan.TicksPerSecond);
|
|
private static readonly TimeSpan MaxLoginBackoff = new TimeSpan(8 * TimeSpan.TicksPerMinute);
|
|
private static readonly TimeSpan TokenValidPeriod = new TimeSpan(120 * TimeSpan.TicksPerSecond);
|
|
|
|
private readonly string _userName;
|
|
private readonly string _password;
|
|
private string? _token;
|
|
private bool? _isVip;
|
|
private int? _vipDaysLeft;
|
|
|
|
private DateTime _nextLoginTrial;
|
|
private TimeSpan _nextLoginBackoff;
|
|
private DateTime _nextValidTrial;
|
|
|
|
private Task<bool>? _ensureValidTask;
|
|
|
|
/// <summary>
|
|
/// Constructs a session from credentials and optionally a possibly live token.
|
|
/// </summary>
|
|
public Session(string userName, string password, string? token = null)
|
|
{
|
|
if (userName == null || password == null)
|
|
throw new ArgumentNullException();
|
|
|
|
this._userName = userName;
|
|
this._password = password;
|
|
this._token = token;
|
|
this._nextLoginTrial = default;
|
|
this._nextLoginBackoff = InitialLoginBackoff;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the user name under which the session is authenticated.
|
|
/// </summary>
|
|
public string UserName => _userName;
|
|
|
|
/// <summary>
|
|
/// Gets an existing VIP session token or creates a new one.
|
|
/// </summary>
|
|
/// <param name="cancel">Asynchronous cancellation.</param>
|
|
/// <returns>Valid VIP token. -or- Null if account credentials are invalid or not with VIP active.</returns>
|
|
public async Task<string?> GetTokenAsync(CancellationToken cancel)
|
|
{
|
|
return (await EnsureValidAsync(cancel)) && (_isVip ?? false) ? _token : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the number of days left in the VIP account.
|
|
/// </summary>
|
|
/// <param name="cancel">Asynchronous cancellation.</param>
|
|
/// <returns>Days left. -or- Null if account credentials are invalid or not with VIP active.</returns>
|
|
public async Task<int?> GetVipDaysLeftAsync(CancellationToken cancel)
|
|
{
|
|
return await EnsureValidAsync(cancel) ? _vipDaysLeft : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes sure the cached instance data are valid. The account still may not be VIP.
|
|
/// </summary>
|
|
/// <param name="cancel"></param>
|
|
/// <returns>True if the local instance data are valid. -or- False otherwise.</returns>
|
|
private async Task<bool> EnsureValidAsync(CancellationToken cancel)
|
|
{
|
|
DateTime now = DateTime.Now;
|
|
|
|
if (_token != null && (_isVip ?? false) && now < _nextValidTrial)
|
|
// Fast track positive check
|
|
return true;
|
|
|
|
// Serialize threads so we do not bounce the server too much
|
|
TaskCompletionSource<bool> task = new TaskCompletionSource<bool>();
|
|
Task<bool>? resultT = Interlocked.CompareExchange(ref _ensureValidTask, task.Task, null);
|
|
if (resultT != null)
|
|
// We are not the thread that will determine it
|
|
return await resultT;
|
|
|
|
bool result = false;
|
|
try
|
|
{
|
|
if (_token != null && await RefreshUserDataAsync(_token, cancel) && (_isVip ?? false))
|
|
{
|
|
// Valid VIP (slower with API check)
|
|
_nextValidTrial = now + TokenValidPeriod;
|
|
return result = true;
|
|
}
|
|
|
|
if (now > _nextLoginTrial)
|
|
{
|
|
// Try to log in again
|
|
string? token;
|
|
try
|
|
{
|
|
string? salt;
|
|
if ((salt = await GetSaltAsync(_userName, cancel)) == null
|
|
|| (token = await GetTokenAsync(_userName, _password.HashPassword(salt), cancel)) == null)
|
|
{
|
|
// Login failed
|
|
token = null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
token = null;
|
|
}
|
|
|
|
if (token != null && await RefreshUserDataAsync(token, cancel) && (_isVip ?? false))
|
|
{
|
|
// Valid token with VIP
|
|
_nextLoginBackoff = InitialLoginBackoff;
|
|
_token = token;
|
|
_nextValidTrial = now + TokenValidPeriod;
|
|
return result = true;
|
|
|
|
}
|
|
else
|
|
{
|
|
// Invalid, no token or not VIP ...
|
|
TimeSpan nextBackoff = new TimeSpan(2 * _nextLoginBackoff.Ticks);
|
|
if (nextBackoff > MaxLoginBackoff)
|
|
nextBackoff = MaxLoginBackoff;
|
|
_nextLoginTrial = now + nextBackoff;
|
|
}
|
|
}
|
|
|
|
// ... still invalid, no token or not VIP
|
|
_token = null;
|
|
_isVip = null;
|
|
_vipDaysLeft = null;
|
|
return result = false;
|
|
}
|
|
finally
|
|
{
|
|
task.SetResult(result);
|
|
GC.KeepAlive(Interlocked.Exchange(ref _ensureValidTask, null));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether user name is valid.
|
|
/// </summary>
|
|
/// <param name="userName">User name to check.</param>
|
|
/// <param name="cancel">Asynchronous cancellation.</param>
|
|
/// <returns>Intermedeiate result usable for password check. -or- <see cref="CredentialCheck.IsNull"/> for invalid user name.</returns>
|
|
public static async Task<CredentialCheck> CheckUserNameAsync(string userName, CancellationToken cancel)
|
|
{
|
|
if (userName == null || userName.Length == 0)
|
|
throw new ArgumentNullException();
|
|
|
|
string? salt = await GetSaltAsync(userName, cancel);
|
|
return salt != null ? new CredentialCheck(userName, salt) : default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if password is valid for the given user name.
|
|
/// </summary>
|
|
/// <param name="userName">User name or email.</param>
|
|
/// <param name="password">Password in plain-text.</param>
|
|
/// <returns>True if password is valid. -or- False otherwise.</returns>
|
|
public static async Task<bool> CheckPasswordAsync(CredentialCheck userName, string password, CancellationToken cancel)
|
|
{
|
|
if (userName.IsNull || password == null)
|
|
throw new ArgumentNullException();
|
|
|
|
string passwordHash = password.HashPassword(userName.Salt);
|
|
return null != await GetTokenAsync(userName.UserName, passwordHash, cancel);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a user name salt.
|
|
/// </summary>
|
|
/// <returns>Non-null if user name is valid. -or- Null otherwise.</returns>
|
|
private static async Task<string?> GetSaltAsync(string userName, CancellationToken cancel)
|
|
{
|
|
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status><salt>somesaltvalue</salt><app_version>30</app_version></response>
|
|
Uri saltUri = new Uri(WebshareApiUri, "salt/");
|
|
HttpResponseMessage saltRes = await Program._http.PostAsync(saltUri, new FormUrlEncodedContent(new[] {
|
|
new KeyValuePair<string, string>("username_or_email", userName),
|
|
}), cancel
|
|
);
|
|
if (!saltRes.IsSuccessStatusCode)
|
|
return null;
|
|
|
|
XmlReader saltR = XmlReader.Create(saltRes.Content.ReadAsStream());
|
|
saltR.ReadStartElement("response");
|
|
Exception? e = saltR.GetExceptionIfStatusNotOK(out string? code);
|
|
if (e != null)
|
|
return null;
|
|
|
|
// Calculate the download password
|
|
string salt = saltR.ReadElementContentAsString("salt", "");
|
|
saltR.ReadElementContentAsString("app_version", "");
|
|
saltR.ReadEndElement(); // response
|
|
|
|
return salt;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the session token.
|
|
/// </summary>
|
|
/// <returns>Non-null if user name is valid. -or- Null otherwise.</returns>
|
|
private static async Task<string?> GetTokenAsync(string userName, string passwordHash, CancellationToken cancel)
|
|
{
|
|
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status><token>sometokenvalue</token><app_version>30</app_version></response>
|
|
Uri tokenUri = new Uri(WebshareApiUri, "login/");
|
|
HttpResponseMessage tokenRes = await Program._http.PostAsync(tokenUri, new FormUrlEncodedContent(new[] {
|
|
new KeyValuePair<string, string>("username_or_email", userName),
|
|
new KeyValuePair<string, string>("password", passwordHash),
|
|
new KeyValuePair<string, string>("keep_logged_in", "1"),
|
|
}), cancel
|
|
);
|
|
if (!tokenRes.IsSuccessStatusCode)
|
|
return null;
|
|
|
|
XmlReader tokenR = XmlReader.Create(tokenRes.Content.ReadAsStream());
|
|
tokenR.ReadStartElement("response");
|
|
Exception? e = tokenR.GetExceptionIfStatusNotOK(out string? code);
|
|
if (e != null)
|
|
return null;
|
|
|
|
// Calculate the download password
|
|
string token = tokenR.ReadElementContentAsString("token", "");
|
|
tokenR.ReadElementContentAsString("app_version", "");
|
|
tokenR.ReadEndElement(); // response
|
|
|
|
return token;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets user data and indirectly validates the session token.
|
|
/// </summary>
|
|
/// <returns>True if refresh has been successful and session token is valid. -or- False otherwise.</returns>
|
|
private async Task<bool> RefreshUserDataAsync(string? token, CancellationToken cancel)
|
|
{
|
|
_isVip = null;
|
|
_vipDaysLeft = null;
|
|
|
|
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status>...??...<app_version>30</app_version></response>
|
|
Uri dataUri = new Uri(WebshareApiUri, "user_data/");
|
|
HttpResponseMessage dataRes = await Program._http.PostAsync(dataUri, new FormUrlEncodedContent(new[] {
|
|
new KeyValuePair<string, string>(TokenKeyName, token ?? "")
|
|
}), cancel
|
|
);
|
|
if (!dataRes.IsSuccessStatusCode)
|
|
return false;
|
|
|
|
XmlReader tokenR = XmlReader.Create(dataRes.Content.ReadAsStream());
|
|
tokenR.ReadStartElement("response");
|
|
Exception? e = tokenR.GetExceptionIfStatusNotOK(out string? code);
|
|
if (e != null)
|
|
return false;
|
|
|
|
// Calculate the download password
|
|
foreach (string skipName in new string[] { "id", "ident", "username", "email", "credits", "points", "files", "bytes",
|
|
"score_files", "score_bytes", "private_files", "private_bytes", "private_space", "tester", "role"})
|
|
tokenR.ReadElementContentAsString(skipName, "");
|
|
|
|
_isVip = tokenR.ReadElementContentAsString("vip", "") == "1";
|
|
if (int.TryParse(tokenR.ReadElementContentAsString("vip_days", ""), CultureInfo.InvariantCulture, out int vipDays))
|
|
_vipDaysLeft = vipDays;
|
|
|
|
foreach (string skipName in new string[] { "vip_hours", "vip_minutes", "vip_until", "terms_version", "email_verified", "wants_newsletters", "wants_https_download" })
|
|
tokenR.ReadElementContentAsString(skipName, "");
|
|
|
|
tokenR.ReadElementContentAsString("app_version", "");
|
|
tokenR.ReadEndElement(); // response
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
}
|