Reworking login requests

This commit is contained in:
Zoe Roux 2021-05-07 01:45:26 +02:00
parent 0f2bea9bc4
commit d9cca97961
17 changed files with 523 additions and 221 deletions

View File

@ -0,0 +1,54 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using IdentityModel;
namespace Kyoo.Authentication
{
public static class PasswordUtils
{
/// <summary>
/// Generate an OneTimeAccessCode.
/// </summary>
/// <returns>A new otac.</returns>
public static string GenerateOTAC()
{
return CryptoRandom.CreateUniqueId();
}
/// <summary>
/// Hash a password to store it has a verification only.
/// </summary>
/// <param name="password">The password to hash</param>
/// <returns>The hashed password</returns>
public static string HashPassword(string password)
{
byte[] salt = new byte[16];
new RNGCryptoServiceProvider().GetBytes(salt);
Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000);
byte[] hash = pbkdf2.GetBytes(20);
byte[] hashBytes = new byte[36];
Array.Copy(salt, 0, hashBytes, 0, 16);
Array.Copy(hash, 0, hashBytes, 16, 20);
return Convert.ToBase64String(hashBytes);
}
/// <summary>
/// Check if a password is the same as a valid hashed password.
/// </summary>
/// <param name="password">The password to check</param>
/// <param name="validPassword">
/// The valid hashed password. This password must be hashed via <see cref="HashPassword"/>.
/// </param>
/// <returns>True if the password is valid, false otherwise.</returns>
public static bool CheckPassword(string password, string validPassword)
{
byte[] validHash = Convert.FromBase64String(validPassword);
byte[] salt = new byte[16];
Array.Copy(validHash, 0, salt, 0, 16);
Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000);
byte[] hash = pbkdf2.GetBytes(20);
return hash.SequenceEqual(validHash.Skip(16));
}
}
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Security.Claims;
using IdentityModel;
using Kyoo.Models;
namespace Kyoo.Authentication
{
/// <summary>
/// Extension methods.
/// </summary>
public static class Extensions
{
public static ClaimsPrincipal ToPrincipal(this User user)
{
List<Claim> claims = new()
{
new Claim(JwtClaimTypes.Subject, user.ID.ToString()),
new Claim(JwtClaimTypes.Name, user.Username),
new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}")
};
ClaimsIdentity id = new (claims);
return new ClaimsPrincipal(id);
}
}
}

View File

@ -19,6 +19,7 @@
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.Storage" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.8.10" />
</ItemGroup>

View File

@ -0,0 +1,28 @@
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A model only used on login requests.
/// </summary>
public class LoginRequest
{
/// <summary>
/// The user's username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// The user's password.
/// </summary>
public string Password { get; set; }
/// <summary>
/// Should the user stay logged in? If true a cookie will be put.
/// </summary>
public bool StayLoggedIn { get; set; }
/// <summary>
/// The return url of the login flow.
/// </summary>
public string ReturnURL { get; set; }
}
}

View File

@ -0,0 +1,18 @@
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A model to represent an otac request
/// </summary>
public class OtacRequest
{
/// <summary>
/// The One Time Access Code
/// </summary>
public string Otac { get; set; }
/// <summary>
/// Should the user stay logged
/// </summary>
public bool StayLoggedIn { get; set; }
}
}

View File

@ -0,0 +1,28 @@
namespace Kyoo.Authentication.Models
{
/// <summary>
/// The main authentication options.
/// </summary>
public class AuthenticationOptions
{
/// <summary>
/// The path to get this option from the root configuration.
/// </summary>
public const string Path = "authentication";
/// <summary>
/// The options for certificates
/// </summary>
public CertificateOption Certificate { get; set; }
/// <summary>
/// Options for permissions
/// </summary>
public PermissionOption Permissions { get; set; }
/// <summary>
/// Root path of user's profile pictures.
/// </summary>
public string ProfilePicturePath { get; set; }
}
}

View File

@ -15,11 +15,11 @@ namespace Kyoo.Authentication.Models
/// <summary>
/// The default permissions that will be given to a non-connected user.
/// </summary>
public ICollection<string> Default { get; set; }
public string[] Default { get; set; }
/// <summary>
/// Permissions applied to a new user.
/// </summary>
public ICollection<string> NewUser { get; set; }
public string[] NewUser { get; set; }
}
}

View File

@ -1,183 +1,218 @@
// using System;
// using System.Collections.Generic;
// using System.IO;
// using System.Linq;
// using System.Security.Claims;
// using System.Threading.Tasks;
// using IdentityServer4.Models;
// using IdentityServer4.Services;
// using Kyoo.Authentication.Models.DTO;
// using Kyoo.Models;
// using Microsoft.AspNetCore.Authorization;
// using Microsoft.AspNetCore.Http;
// using Microsoft.AspNetCore.Identity;
// using Microsoft.AspNetCore.Mvc;
// using Microsoft.Extensions.Configuration;
// using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
//
// namespace Kyoo.Authentication.Views
// {
// public class LoginRequest
// {
// public string Username { get; set; }
// public string Password { get; set; }
// public bool StayLoggedIn { get; set; }
// }
//
// public class OtacRequest
// {
// public string Otac { get; set; }
// public bool StayLoggedIn { get; set; }
// }
//
// public class AccountData
// {
// [FromForm(Name = "email")]
// public string Email { get; set; }
// [FromForm(Name = "username")]
// public string Username { get; set; }
// [FromForm(Name = "picture")]
// public IFormFile Picture { get; set; }
// }
//
//
// /// <summary>
// /// The class responsible for login, logout, permissions and claims of a user.
// /// </summary>
// [Route("api/account")]
// [Route("api/accounts")]
// [ApiController]
// public class AccountApi : Controller, IProfileService
// {
// private readonly UserManager<User> _userManager;
// private readonly SignInManager<User> _signInManager;
// private readonly IConfiguration _configuration;
// private readonly string _picturePath;
//
// // TODO find how SignInManager & UserManager are implement and check if they can be used or not.
// public AccountApi(UserManager<User> userManager,
// SignInManager<User> signInManager,
// IConfiguration configuration)
// {
// _userManager = userManager;
// _signInManager = signInManager;
// _picturePath = configuration.GetValue<string>("profilePicturePath");
// _configuration = configuration;
// if (!Path.IsPathRooted(_picturePath))
// _picturePath = Path.GetFullPath(_picturePath);
// }
//
// [HttpPost("register")]
// public async Task<IActionResult> Register([FromBody] RegisterRequest request)
// {
// User user = request.ToUser();
// IdentityResult result = await _userManager.CreateAsync(user, user.Password);
// if (!result.Succeeded)
// return BadRequest(result.Errors);
// string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1));
// await _userManager.UpdateAsync(account);
// await _userManager.AddClaimAsync(account, new Claim(
// "permissions",
// _configuration.GetValue<string>("newUserPermissions")));
// return Ok(new {otac});
// }
//
// [HttpPost("login")]
// public async Task<IActionResult> Login([FromBody] LoginRequest login)
// {
// if (!ModelState.IsValid)
// return BadRequest(login);
// SignInResult result = await _signInManager
// .PasswordSignInAsync(login.Username, login.Password, login.StayLoggedIn, false);
// if (result.Succeeded)
// return Ok();
// return BadRequest(new [] { new {code = "InvalidCredentials", description = "Invalid username/password"}});
// }
//
// [HttpPost("otac-login")]
// public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
// {
// if (!ModelState.IsValid)
// return BadRequest(otac);
// User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac);
// if (user == null)
// return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}});
// if (user.OTACExpires <= DateTime.UtcNow)
// return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}});
// await _signInManager.SignInAsync(user, otac.StayLoggedIn);
// return Ok();
// }
//
// [HttpGet("logout")]
// [Authorize]
// public async Task<IActionResult> Logout()
// {
// await _signInManager.SignOutAsync();
// return Ok();
// }
//
// public async Task GetProfileDataAsync(ProfileDataRequestContext context)
// {
// User user = await _userManager.GetUserAsync(context.Subject);
// if (user != null)
// {
// List<Claim> claims = new()
// {
// new Claim("email", user.Email),
// new Claim("username", user.UserName),
// new Claim("picture", $"api/account/picture/{user.UserName}")
// };
//
// Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions");
// if (perms != null)
// claims.Add(perms);
//
// context.IssuedClaims.AddRange(claims);
// }
// }
//
// public async Task IsActiveAsync(IsActiveContext context)
// {
// User user = await _userManager.GetUserAsync(context.Subject);
// context.IsActive = user != null;
// }
//
// [HttpGet("picture/{username}")]
// public async Task<IActionResult> GetPicture(string username)
// {
// User user = await _userManager.FindByNameAsync(username);
// if (user == null)
// return BadRequest();
// string path = Path.Combine(_picturePath, user.Id);
// if (!System.IO.File.Exists(path))
// return NotFound();
// return new PhysicalFileResult(path, "image/png");
// }
//
// [HttpPost("update")]
// [Authorize]
// public async Task<IActionResult> Update([FromForm] AccountData data)
// {
// User user = await _userManager.GetUserAsync(HttpContext.User);
//
// if (!string.IsNullOrEmpty(data.Email))
// user.Email = data.Email;
// if (!string.IsNullOrEmpty(data.Username))
// user.UserName = data.Username;
// if (data.Picture?.Length > 0)
// {
// string path = Path.Combine(_picturePath, user.Id);
// await using FileStream file = System.IO.File.Create(path);
// await data.Picture.CopyToAsync(file);
// }
// await _userManager.UpdateAsync(user);
// return Ok();
// }
//
// [HttpGet("default-permissions")]
// public ActionResult<IEnumerable<string>> GetDefaultPermissions()
// {
// return _configuration.GetValue<string>("defaultPermissions").Split(",");
// }
// }
// }
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityModel;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Kyoo.Authentication.Models.DTO;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using AuthenticationOptions = Kyoo.Authentication.Models.AuthenticationOptions;
namespace Kyoo.Authentication.Views
{
public class AccountData
{
[FromForm(Name = "email")]
public string Email { get; set; }
[FromForm(Name = "username")]
public string Username { get; set; }
[FromForm(Name = "picture")]
public IFormFile Picture { get; set; }
}
/// <summary>
/// The class responsible for login, logout, permissions and claims of a user.
/// </summary>
[Route("api/account")]
[Route("api/accounts")]
[ApiController]
public class AccountApi : Controller, IProfileService
{
/// <summary>
/// The repository to handle users.
/// </summary>
private readonly IUserRepository _users;
/// <summary>
/// The identity server interaction service to login users.
/// </summary>
private readonly IIdentityServerInteractionService _interaction;
/// <summary>
/// Options about authentication. Those options are monitored and reloads are supported.
/// </summary>
private readonly IOptionsMonitor<AuthenticationOptions> _options;
/// <summary>
/// Create a new <see cref="AccountApi"/> handle to handle login/users requests.
/// </summary>
/// <param name="users">The user repository to create and manage users</param>
/// <param name="interaction">The identity server interaction service to login users.</param>
/// <param name="options">Authentication options (this may be hot reloaded)</param>
public AccountApi(IUserRepository users,
IIdentityServerInteractionService interaction,
IOptionsMonitor<AuthenticationOptions> options)
{
_users = users;
_interaction = interaction;
_options = options;
}
/// <summary>
/// Register a new user and return a OTAC to connect to it.
/// </summary>
/// <param name="request">The DTO register request</param>
/// <returns>A OTAC to connect to this new account</returns>
[HttpPost("register")]
public async Task<ActionResult<string>> Register([FromBody] RegisterRequest request)
{
User user = request.ToUser();
user.Permissions = _options.CurrentValue.Permissions.NewUser;
user.Password = PasswordUtils.HashPassword(user.Password);
user.ExtraData["otac"] = PasswordUtils.GenerateOTAC();
user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s");
await _users.Create(user);
return user.ExtraData["otac"];
}
/// <summary>
/// Return an authentication properties based on a stay login property
/// </summary>
/// <param name="stayLogged">Should the user stay logged</param>
/// <returns>Authentication properties based on a stay login</returns>
private static AuthenticationProperties StayLogged(bool stayLogged)
{
if (!stayLogged)
return null;
return new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(1)
};
}
/// <summary>
/// Login the user.
/// </summary>
/// <param name="login">The DTO login request</param>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest login)
{
AuthorizationRequest context = await _interaction.GetAuthorizationContextAsync(login.ReturnURL);
User user = await _users.Get(x => x.Username == login.Username);
if (context == null || user == null)
return Unauthorized();
if (!PasswordUtils.CheckPassword(login.Password, user.Password))
return Unauthorized();
await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(login.StayLoggedIn));
return Ok(new { RedirectUrl = login.ReturnURL, IsOk = true });
}
/// <summary>
/// Use a OTAC to login a user.
/// </summary>
/// <param name="otac">The OTAC request</param>
[HttpPost("otac-login")]
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
{
User user = await _users.Get(x => x.ExtraData["OTAC"] == otac.Otac);
if (user == null)
return Unauthorized();
if (DateTime.ParseExact(user.ExtraData["otac-expire"], "s", CultureInfo.InvariantCulture) <= DateTime.UtcNow)
return BadRequest(new
{
code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."
});
await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(otac.StayLoggedIn));
return Ok();
}
/// <summary>
/// Sign out an user
/// </summary>
[HttpGet("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync();
return Ok();
}
// TODO check with the extension method
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
User user = await _userManager.GetUserAsync(context.Subject);
if (user != null)
{
List<Claim> claims = new()
{
new Claim(JwtClaimTypes.Email, user.Email),
new Claim(JwtClaimTypes.Name, user.Username),
new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}")
};
Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions");
if (perms != null)
claims.Add(perms);
context.IssuedClaims.AddRange(claims);
}
}
public async Task IsActiveAsync(IsActiveContext context)
{
User user = await _userManager.GetUserAsync(context.Subject);
context.IsActive = user != null;
}
[HttpGet("picture/{username}")]
public async Task<IActionResult> GetPicture(string username)
{
User user = await _userManager.FindByNameAsync(username);
if (user == null)
return BadRequest();
string path = Path.Combine(_picturePath, user.Id);
if (!System.IO.File.Exists(path))
return NotFound();
return new PhysicalFileResult(path, "image/png");
}
[HttpPost("update")]
[Authorize]
public async Task<IActionResult> Update([FromForm] AccountData data)
{
User user = await _userManager.GetUserAsync(HttpContext.User);
if (!string.IsNullOrEmpty(data.Email))
user.Email = data.Email;
if (!string.IsNullOrEmpty(data.Username))
user.UserName = data.Username;
if (data.Picture?.Length > 0)
{
string path = Path.Combine(_picturePath, user.Id);
await using FileStream file = System.IO.File.Create(path);
await data.Picture.CopyToAsync(file);
}
await _userManager.UpdateAsync(user);
return Ok();
}
[HttpGet("default-permissions")]
public ActionResult<IEnumerable<string>> GetDefaultPermissions()
{
return _configuration.GetValue<string>("defaultPermissions").Split(",");
}
}
}

View File

@ -11,7 +11,7 @@ using Kyoo.Models.Exceptions;
namespace Kyoo.Controllers
{
/// <summary>
/// Informations about the pagination. How many items should be displayed and where to start.
/// Information about the pagination. How many items should be displayed and where to start.
/// </summary>
public readonly struct Pagination
{
@ -44,7 +44,7 @@ namespace Kyoo.Controllers
}
/// <summary>
/// Informations about how a query should be sorted. What factor should decide the sort and in which order.
/// Information about how a query should be sorted. What factor should decide the sort and in which order.
/// </summary>
/// <typeparam name="T">For witch type this sort applies</typeparam>
public readonly struct Sort<T>
@ -54,7 +54,7 @@ namespace Kyoo.Controllers
/// </summary>
public Expression<Func<T, object>> Key { get; }
/// <summary>
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendent order.
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
/// </summary>
public bool Descendant { get; }
@ -175,7 +175,7 @@ namespace Kyoo.Controllers
/// Get every resources that match all filters
/// </summary>
/// <param name="where">A filter predicate</param>
/// <param name="sort">Sort informations about the query (sort by, sort order)</param>
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
/// <returns>A list of resources that match every filters</returns>
Task<ICollection<T>> GetAll(Expression<Func<T, bool>> where = null,
@ -205,24 +205,24 @@ namespace Kyoo.Controllers
/// Create a new resource.
/// </summary>
/// <param name="obj">The item to register</param>
/// <returns>The resource registers and completed by database's informations (related items & so on)</returns>
/// <returns>The resource registers and completed by database's information (related items & so on)</returns>
Task<T> Create([NotNull] T obj);
/// <summary>
/// Create a new resource if it does not exist already. If it does, the existing value is returned instead.
/// </summary>
/// <param name="obj">The object to create</param>
/// <param name="silentFail">Allow issues to occurs in this method. Every issue is catched and ignored.</param>
/// <param name="silentFail">Allow issues to occurs in this method. Every issue is caught and ignored.</param>
/// <returns>The newly created item or the existing value if it existed.</returns>
Task<T> CreateIfNotExists([NotNull] T obj, bool silentFail = false);
/// <summary>
/// Edit a resource
/// </summary>
/// <param name="edited">The resourcce to edit, it's ID can't change.</param>
/// <param name="edited">The resource to edit, it's ID can't change.</param>
/// <param name="resetOld">Should old properties of the resource be discarded or should null values considered as not changed?</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's informations (related items & so on)</returns>
/// <returns>The resource edited and completed by database's information (related items & so on)</returns>
Task<T> Edit([NotNull] T edited, bool resetOld);
/// <summary>
@ -259,25 +259,25 @@ namespace Kyoo.Controllers
/// <summary>
/// Delete a list of resources.
/// </summary>
/// <param name="ids">One or multiple resources's id</param>
/// <param name="ids">One or multiple resource's id</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
Task DeleteRange(params int[] ids) => DeleteRange(ids.AsEnumerable());
/// <summary>
/// Delete a list of resources.
/// </summary>
/// <param name="ids">An enumearble of resources's id</param>
/// <param name="ids">An enumerable of resource's id</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
Task DeleteRange(IEnumerable<int> ids);
/// <summary>
/// Delete a list of resources.
/// </summary>
/// <param name="slugs">One or multiple resources's slug</param>
/// <param name="slugs">One or multiple resource's slug</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
Task DeleteRange(params string[] slugs) => DeleteRange(slugs.AsEnumerable());
/// <summary>
/// Delete a list of resources.
/// </summary>
/// <param name="slugs">An enumerable of resources's slug</param>
/// <param name="slugs">An enumerable of resource's slug</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
Task DeleteRange(IEnumerable<string> slugs);
/// <summary>
@ -294,7 +294,7 @@ namespace Kyoo.Controllers
public interface IShowRepository : IRepository<Show>
{
/// <summary>
/// Link a show to a collection and/or a library. The given show is now part of thoses containers.
/// Link a show to a collection and/or a library. The given show is now part of those containers.
/// If both a library and a collection are given, the collection is added to the library too.
/// </summary>
/// <param name="showID">The ID of the show</param>
@ -421,7 +421,7 @@ namespace Kyoo.Controllers
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The tracl found</returns>
/// <returns>The track found</returns>
Task<Track> Get(string slug, StreamType type = StreamType.Unknown);
/// <summary>
@ -429,7 +429,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <returns>The tracl found</returns>
/// <returns>The track found</returns>
Task<Track> GetOrDefault(string slug, StreamType type = StreamType.Unknown);
}
@ -439,16 +439,16 @@ namespace Kyoo.Controllers
public interface ILibraryRepository : IRepository<Library> { }
/// <summary>
/// A repository to handle library items (A wrapper arround shows and collections).
/// A repository to handle library items (A wrapper around shows and collections).
/// </summary>
public interface ILibraryItemRepository : IRepository<LibraryItem>
{
/// <summary>
/// Get items (A wrapper arround shows or collections) from a library.
/// Get items (A wrapper around shows or collections) from a library.
/// </summary>
/// <param name="id">The ID of the library</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
public Task<ICollection<LibraryItem>> GetFromLibrary(int id,
@ -456,7 +456,7 @@ namespace Kyoo.Controllers
Sort<LibraryItem> sort = default,
Pagination limit = default);
/// <summary>
/// Get items (A wrapper arround shows or collections) from a library.
/// Get items (A wrapper around shows or collections) from a library.
/// </summary>
/// <param name="id">The ID of the library</param>
/// <param name="where">A filter function</param>
@ -470,11 +470,11 @@ namespace Kyoo.Controllers
) => GetFromLibrary(id, where, new Sort<LibraryItem>(sort), limit);
/// <summary>
/// Get items (A wrapper arround shows or collections) from a library.
/// Get items (A wrapper around shows or collections) from a library.
/// </summary>
/// <param name="slug">The slug of the library</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
public Task<ICollection<LibraryItem>> GetFromLibrary(string slug,
@ -482,7 +482,7 @@ namespace Kyoo.Controllers
Sort<LibraryItem> sort = default,
Pagination limit = default);
/// <summary>
/// Get items (A wrapper arround shows or collections) from a library.
/// Get items (A wrapper around shows or collections) from a library.
/// </summary>
/// <param name="slug">The slug of the library</param>
/// <param name="where">A filter function</param>
@ -521,7 +521,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="showID">The ID of the show</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
Task<ICollection<PeopleRole>> GetFromShow(int showID,
@ -547,7 +547,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="showSlug">The slug of the show</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
Task<ICollection<PeopleRole>> GetFromShow(string showSlug,
@ -573,7 +573,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="id">The id of the person</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
Task<ICollection<PeopleRole>> GetFromPeople(int id,
@ -599,7 +599,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="slug">The slug of the person</param>
/// <param name="where">A filter function</param>
/// <param name="sort">Sort informations (sort order & sort by)</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">How many items to return and where to start</param>
/// <returns>A list of items that match every filters</returns>
Task<ICollection<PeopleRole>> GetFromPeople(string slug,
@ -631,7 +631,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="where">A predicate to add arbitrary filter</param>
/// <param name="sort">Sort information (sort order & sort by)</param>
/// <param name="limit">Paginations information (where to start and how many to get)</param>
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <returns>A filtered list of external ids.</returns>
Task<ICollection<MetadataID>> GetMetadataID(Expression<Func<MetadataID, bool>> where = null,
Sort<MetadataID> sort = default,
@ -642,11 +642,16 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="where">A predicate to add arbitrary filter</param>
/// <param name="sort">A sort by expression</param>
/// <param name="limit">Paginations information (where to start and how many to get)</param>
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <returns>A filtered list of external ids.</returns>
Task<ICollection<MetadataID>> GetMetadataID([Optional] Expression<Func<MetadataID, bool>> where,
Expression<Func<MetadataID, object>> sort,
Pagination limit = default
) => GetMetadataID(where, new Sort<MetadataID>(sort), limit);
}
/// <summary>
/// A repository to handle users.
/// </summary>
public interface IUserRepository : IRepository<User> {}
}

View File

@ -28,6 +28,16 @@ namespace Kyoo.Models
/// </summary>
public string Password { get; set; }
/// <summary>
/// The list of permissions of the user. The format of this is implementation dependent.
/// </summary>
public string[] Permissions { get; set; }
/// <summary>
/// Arbitrary extra data that can be used by specific authentication implementations.
/// </summary>
public Dictionary<string, string> ExtraData { get; set; }
/// <summary>
/// The list of shows the user has finished.
/// </summary>
@ -36,6 +46,18 @@ namespace Kyoo.Models
/// <summary>
/// The list of episodes the user is watching (stopped in progress or the next episode of the show)
/// </summary>
public ICollection<(Episode episode, int watchedPercentage)> CurrentlyWatching { get; set; }
public ICollection<WatchedEpisode> CurrentlyWatching { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// Links between Users and Shows.
/// </summary>
public ICollection<Link<User, Show>> ShowLinks { get; set; }
/// <summary>
/// Links between Users and WatchedEpisodes.
/// </summary>
public ICollection<Link<User, WatchedEpisode>> EpisodeLinks { get; set; }
#endif
}
}

View File

@ -0,0 +1,30 @@
using Kyoo.Models.Attributes;
namespace Kyoo.Models
{
/// <summary>
/// Metadata of episode currently watching by an user
/// </summary>
public class WatchedEpisode : IResource
{
/// <inheritdoc />
[SerializeIgnore] public int ID
{
get => Episode.ID;
set => Episode.ID = value;
}
/// <inheritdoc />
[SerializeIgnore] public string Slug => Episode.Slug;
/// <summary>
/// The episode currently watched
/// </summary>
public Episode Episode { get; set; }
/// <summary>
/// Where the player has stopped watching the episode (-1 if not started, else between 0 and 100).
/// </summary>
public int WatchedPercentage { get; set; }
}
}

View File

@ -215,7 +215,7 @@ namespace Kyoo
/// <summary>
/// An advanced <see cref="Complete{T}"/> function.
/// This will set missing values of <see cref="first"/> to the corresponding values of <see cref="second"/>.
/// Enumerables will be merged (concatened).
/// Enumerable will be merged (concatenated).
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>.
/// </summary>
/// <param name="first">The object to complete</param>

View File

@ -67,7 +67,7 @@ namespace Kyoo
/// <summary>
/// The list of registered users.
/// </summary>
// public DbSet<User> Users { get; set; }
public DbSet<User> Users { get; set; }
/// <summary>
/// All people's role. See <see cref="PeopleRole"/>.
@ -254,7 +254,7 @@ namespace Kyoo
/// Return a new or an in cache temporary object wih the same ID as the one given
/// </summary>
/// <param name="model">If a resource with the same ID is found in the database, it will be used.
/// <see cref="model"/> will be used overwise</param>
/// <see cref="model"/> will be used otherwise</param>
/// <typeparam name="T">The type of the resource</typeparam>
/// <returns>A resource that is now tracked by this context.</returns>
public T GetTemporaryObject<T>(T model)

View File

@ -118,7 +118,7 @@ namespace Kyoo.Controllers
/// <param name="query">The base query to filter.</param>
/// <param name="where">An expression to filter based on arbitrary conditions</param>
/// <param name="sort">The sort settings (sort order & sort by)</param>
/// <param name="limit">Paginations information (where to start and how many to get)</param>
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <returns>The filtered query</returns>
protected Task<ICollection<T>> ApplyFilters(IQueryable<T> query,
Expression<Func<T, bool>> where = null,
@ -137,7 +137,7 @@ namespace Kyoo.Controllers
/// <param name="query">The base query to filter.</param>
/// <param name="where">An expression to filter based on arbitrary conditions</param>
/// <param name="sort">The sort settings (sort order & sort by)</param>
/// <param name="limit">Paginations information (where to start and how many to get)</param>
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <returns>The filtered query</returns>
protected async Task<ICollection<TValue>> ApplyFilters<TValue>(IQueryable<TValue> query,
Func<int, Task<TValue>> get,
@ -244,7 +244,7 @@ namespace Kyoo.Controllers
}
/// <summary>
/// An overridable method to edit relatiosn of a resource.
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">The non edited resource</param>
/// <param name="changed">The new version of <see cref="resource"/>. This item will be saved on the databse and replace <see cref="resource"/></param>

View File

@ -33,7 +33,7 @@ namespace Kyoo.Controllers
} catch (Exception ex)
{
await Console.Error.WriteLineAsync(
$"The provider {provider.Provider.Name} coudln't work for {what}. Exception: {ex.Message}");
$"The provider {provider.Provider.Name} could not work for {what}. Exception: {ex.Message}");
}
}
return ret;

View File

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Controllers
{
/// <summary>
/// A repository for users.
/// </summary>
public class UserRepository : LocalRepository<User>, IUserRepository
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <inheritdoc />
protected override Expression<Func<User, object>> DefaultSort => x => x.Username;
/// <summary>
/// Create a new <see cref="UserRepository"/>
/// </summary>
/// <param name="database">The database handle to use</param>
public UserRepository(DatabaseContext database)
: base(database)
{
_database = database;
}
/// <inheritdoc />
public override async Task<ICollection<User>> Search(string query)
{
return await _database.Users
.Where(_database.Like<User>(x => x.Username, $"%{query}%"))
.OrderBy(DefaultSort)
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task Delete(User obj)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
}
}
}