Files
stream-cinema/CinemaLib/Webshare/Session.cs

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