From 0f2bea9bc459507d0240a204950b0025d3cacc3f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 6 May 2021 01:40:19 +0200 Subject: [PATCH] Starting to rework authentication --- Kyoo.Authentication/AuthenticationModule.cs | 16 +- .../Kyoo.Authentication.csproj | 3 - .../Models/DTO/RegisterRequest.cs | 45 +++ Kyoo.Authentication/User.cs | 44 +-- Kyoo.Authentication/Views/AccountApi.cs | 374 +++++++++--------- Kyoo.Common/Models/Resources/IResource.cs | 37 +- Kyoo.Common/Models/Resources/User.cs | 41 ++ Kyoo.Common/Utility.cs | 66 ---- Kyoo.CommonAPI/DatabaseContext.cs | 4 + Kyoo/Startup.cs | 4 +- Kyoo/settings.json | 4 +- 11 files changed, 323 insertions(+), 315 deletions(-) create mode 100644 Kyoo.Authentication/Models/DTO/RegisterRequest.cs create mode 100644 Kyoo.Common/Models/Resources/User.cs diff --git a/Kyoo.Authentication/AuthenticationModule.cs b/Kyoo.Authentication/AuthenticationModule.cs index e49cee9b..82d05cce 100644 --- a/Kyoo.Authentication/AuthenticationModule.cs +++ b/Kyoo.Authentication/AuthenticationModule.cs @@ -64,7 +64,7 @@ namespace Kyoo.Authentication /// public void Configure(IServiceCollection services, ICollection availableTypes) { - string publicUrl = _configuration.GetValue("public_url"); + string publicUrl = _configuration.GetValue("public_url").TrimEnd('/'); // services.AddDbContext(options => // { @@ -86,9 +86,9 @@ namespace Kyoo.Authentication services.AddIdentityServer(options => { options.IssuerUri = publicUrl; - options.UserInteraction.LoginUrl = publicUrl + "login"; - options.UserInteraction.ErrorUrl = publicUrl + "error"; - options.UserInteraction.LogoutUrl = publicUrl + "logout"; + options.UserInteraction.LoginUrl = $"{publicUrl}/login"; + options.UserInteraction.ErrorUrl = $"{publicUrl}/error"; + options.UserInteraction.LogoutUrl = $"{publicUrl}/logout"; }) // .AddAspNetIdentity() // .AddConfigurationStore(options => @@ -105,11 +105,13 @@ namespace Kyoo.Authentication // options.EnableTokenCleanup = true; // }) .AddInMemoryIdentityResources(IdentityContext.GetIdentityResources()) - .AddInMemoryApiScopes(IdentityContext.GetScopes()) .AddInMemoryApiResources(IdentityContext.GetApis()) .AddInMemoryClients(IdentityContext.GetClients()) - // .AddProfileService() - .AddSigninKeys(certificateOptions); + .AddDeveloperSigningCredential(); + // .AddProfileService() + // .AddSigninKeys(certificateOptions); + // TODO implement means to add clients or api scopes for other plugins. + // TODO split scopes (kyoo.read should be task.read, video.read etc) services.AddAuthentication(o => { diff --git a/Kyoo.Authentication/Kyoo.Authentication.csproj b/Kyoo.Authentication/Kyoo.Authentication.csproj index 17107035..7bb0a7fb 100644 --- a/Kyoo.Authentication/Kyoo.Authentication.csproj +++ b/Kyoo.Authentication/Kyoo.Authentication.csproj @@ -17,9 +17,6 @@ - - - diff --git a/Kyoo.Authentication/Models/DTO/RegisterRequest.cs b/Kyoo.Authentication/Models/DTO/RegisterRequest.cs new file mode 100644 index 00000000..4d8efa2c --- /dev/null +++ b/Kyoo.Authentication/Models/DTO/RegisterRequest.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using Kyoo.Models; + +namespace Kyoo.Authentication.Models.DTO +{ + /// + /// A model only used on register requests. + /// + public class RegisterRequest + { + /// + /// The user email address + /// + [EmailAddress] + public string Email { get; set; } + + /// + /// The user's username. + /// + [MinLength(4)] + public string Username { get; set; } + + /// + /// The user's password. + /// + [MinLength(8)] + public string Password { get; set; } + + + /// + /// Convert this register request to a new class. + /// + /// + public User ToUser() + { + return new() + { + Slug = Utility.ToSlug(Username), + Username = Username, + Password = Password, + Email = Email + }; + } + } +} \ No newline at end of file diff --git a/Kyoo.Authentication/User.cs b/Kyoo.Authentication/User.cs index 2a65d409..896d3004 100644 --- a/Kyoo.Authentication/User.cs +++ b/Kyoo.Authentication/User.cs @@ -1,22 +1,22 @@ -using System; -using IdentityModel; -using Microsoft.AspNetCore.Identity; - -namespace Kyoo.Models -{ - public class User : IdentityUser - { - public string OTAC { get; set; } - public DateTime? OTACExpires { get; set; } - - public string GenerateOTAC(TimeSpan validFor) - { - string otac = CryptoRandom.CreateUniqueId(); - string hashed = otac; // TODO should add a good hashing here. - - OTAC = hashed; - OTACExpires = DateTime.UtcNow.Add(validFor); - return otac; - } - } -} \ No newline at end of file +// using System; +// using IdentityModel; +// using Microsoft.AspNetCore.Identity; +// +// namespace Kyoo.Models +// { +// public class User : IdentityUser +// { +// public string OTAC { get; set; } +// public DateTime? OTACExpires { get; set; } +// +// public string GenerateOTAC(TimeSpan validFor) +// { +// string otac = CryptoRandom.CreateUniqueId(); +// string hashed = otac; // TODO should add a good hashing here. +// +// OTAC = hashed; +// OTACExpires = DateTime.UtcNow.Add(validFor); +// return otac; +// } +// } +// } \ No newline at end of file diff --git a/Kyoo.Authentication/Views/AccountApi.cs b/Kyoo.Authentication/Views/AccountApi.cs index 7acf5226..de47d57d 100644 --- a/Kyoo.Authentication/Views/AccountApi.cs +++ b/Kyoo.Authentication/Views/AccountApi.cs @@ -1,191 +1,183 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.IO; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using IdentityServer4.Models; -using IdentityServer4.Services; -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.Api -{ - public class RegisterRequest - { - public string Email { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } - - 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; } - } - - - [Route("api/[controller]")] - [ApiController] - public class AccountController : Controller, IProfileService - { - private readonly UserManager _userManager; - private readonly SignInManager _signInManager; - private readonly IConfiguration _configuration; - private readonly string _picturePath; - - public AccountController(UserManager userManager, - SignInManager siginInManager, - IConfiguration configuration) - { - _userManager = userManager; - _signInManager = siginInManager; - _picturePath = configuration.GetValue("profilePicturePath"); - _configuration = configuration; - if (!Path.IsPathRooted(_picturePath)) - _picturePath = Path.GetFullPath(_picturePath); - } - - [HttpPost("register")] - public async Task Register([FromBody] RegisterRequest user) - { - if (!ModelState.IsValid) - return BadRequest(user); - if (user.Username.Length < 4) - return BadRequest(new[] {new {code = "username", description = "Username must be at least 4 characters."}}); - if (!new EmailAddressAttribute().IsValid(user.Email)) - return BadRequest(new[] {new {code = "email", description = "Email must be valid."}}); - User account = new() {UserName = user.Username, Email = user.Email}; - IdentityResult result = await _userManager.CreateAsync(account, 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("newUserPermissions"))); - return Ok(new {otac}); - } - - [HttpPost("login")] - public async Task 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 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 Logout() - { - await _signInManager.SignOutAsync(); - return Ok(); - } - - public async Task GetProfileDataAsync(ProfileDataRequestContext context) - { - User user = await _userManager.GetUserAsync(context.Subject); - if (user != null) - { - List 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 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 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> GetDefaultPermissions() - { - return _configuration.GetValue("defaultPermissions").Split(","); - } - } -} \ No newline at end of file +// 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; } +// } +// +// +// /// +// /// The class responsible for login, logout, permissions and claims of a user. +// /// +// [Route("api/account")] +// [Route("api/accounts")] +// [ApiController] +// public class AccountApi : Controller, IProfileService +// { +// private readonly UserManager _userManager; +// private readonly SignInManager _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 userManager, +// SignInManager signInManager, +// IConfiguration configuration) +// { +// _userManager = userManager; +// _signInManager = signInManager; +// _picturePath = configuration.GetValue("profilePicturePath"); +// _configuration = configuration; +// if (!Path.IsPathRooted(_picturePath)) +// _picturePath = Path.GetFullPath(_picturePath); +// } +// +// [HttpPost("register")] +// public async Task 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("newUserPermissions"))); +// return Ok(new {otac}); +// } +// +// [HttpPost("login")] +// public async Task 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 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 Logout() +// { +// await _signInManager.SignOutAsync(); +// return Ok(); +// } +// +// public async Task GetProfileDataAsync(ProfileDataRequestContext context) +// { +// User user = await _userManager.GetUserAsync(context.Subject); +// if (user != null) +// { +// List 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 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 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> GetDefaultPermissions() +// { +// return _configuration.GetValue("defaultPermissions").Split(","); +// } +// } +// } \ No newline at end of file diff --git a/Kyoo.Common/Models/Resources/IResource.cs b/Kyoo.Common/Models/Resources/IResource.cs index 297f3b1d..c4c4231b 100644 --- a/Kyoo.Common/Models/Resources/IResource.cs +++ b/Kyoo.Common/Models/Resources/IResource.cs @@ -1,30 +1,23 @@ -using System; -using System.Collections.Generic; - namespace Kyoo.Models { + /// + /// An interface to represent a resource that can be retrieved from the database. + /// public interface IResource { + /// + /// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. + /// public int ID { get; set; } + + /// + /// A human-readable identifier that can be used instead of an ID. + /// A slug must be unique for a type of resource but it can be changed. + /// + /// + /// There is no setter for a slug since it can be computed from other fields. + /// For example, a season slug is {ShowSlug}-s{SeasonNumber}. + /// public string Slug { get; } } - - public class ResourceComparer : IEqualityComparer where T : IResource - { - public bool Equals(T x, T y) - { - if (ReferenceEquals(x, y)) - return true; - if (ReferenceEquals(x, null)) - return false; - if (ReferenceEquals(y, null)) - return false; - return x.ID == y.ID || x.Slug == y.Slug; - } - - public int GetHashCode(T obj) - { - return HashCode.Combine(obj.ID, obj.Slug); - } - } } \ No newline at end of file diff --git a/Kyoo.Common/Models/Resources/User.cs b/Kyoo.Common/Models/Resources/User.cs new file mode 100644 index 00000000..a4367c11 --- /dev/null +++ b/Kyoo.Common/Models/Resources/User.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace Kyoo.Models +{ + /// + /// A single user of the app. + /// + public class User : IResource + { + /// + public int ID { get; set; } + + /// + public string Slug { get; set; } + + /// + /// A username displayed to the user. + /// + public string Username { get; set; } + + /// + /// The user email address. + /// + public string Email { get; set; } + + /// + /// The user password (hashed, it can't be read like that). The hashing format is implementation defined. + /// + public string Password { get; set; } + + /// + /// The list of shows the user has finished. + /// + public ICollection Watched { get; set; } + + /// + /// The list of episodes the user is watching (stopped in progress or the next episode of the show) + /// + public ICollection<(Episode episode, int watchedPercentage)> CurrentlyWatching { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Utility.cs b/Kyoo.Common/Utility.cs index bbbe177b..ff44761a 100644 --- a/Kyoo.Common/Utility.cs +++ b/Kyoo.Common/Utility.cs @@ -712,72 +712,6 @@ namespace Kyoo }, TaskContinuationOptions.ExecuteSynchronously); } - public static Expression> ResourceEquals(IResource obj) - where T : IResource - { - if (obj.ID > 0) - return x => x.ID == obj.ID || x.Slug == obj.Slug; - return x => x.Slug == obj.Slug; - } - - public static Func ResourceEqualsFunc(IResource obj) - where T : IResource - { - if (obj.ID > 0) - return x => x.ID == obj.ID || x.Slug == obj.Slug; - return x => x.Slug == obj.Slug; - } - - public static bool ResourceEquals([CanBeNull] object first, [CanBeNull] object second) - { - if (ReferenceEquals(first, second)) - return true; - if (first is IResource f && second is IResource s) - return ResourceEquals(f, s); - IEnumerable eno = first as IEnumerable; - IEnumerable ens = second as IEnumerable; - if (eno == null || ens == null) - throw new ArgumentException("Arguments are not resources or lists of resources."); - Type type = GetEnumerableType(eno); - if (typeof(IResource).IsAssignableFrom(type)) - return ResourceEquals(eno.Cast(), ens.Cast()); - return RunGenericMethod(typeof(Enumerable), "SequenceEqual", type, first, second); - } - - public static bool ResourceEquals([CanBeNull] T first, [CanBeNull] T second) - where T : IResource - { - if (ReferenceEquals(first, second)) - return true; - if (first == null || second == null) - return false; - return first.ID == second.ID || first.Slug == second.Slug; - } - - public static bool ResourceEquals([CanBeNull] IEnumerable first, [CanBeNull] IEnumerable second) - where T : IResource - { - if (ReferenceEquals(first, second)) - return true; - if (first == null || second == null) - return false; - return first.SequenceEqual(second, new ResourceComparer()); - } - - public static bool LinkEquals([CanBeNull] T first, int? firstID, [CanBeNull] T second, int? secondID) - where T : IResource - { - if (ResourceEquals(first, second)) - return true; - if (first == null && second != null - && firstID == second.ID) - return true; - if (first != null && second == null - && first.ID == secondID) - return true; - return firstID == secondID; - } - /// /// Get a friendly type name (supporting generics) /// For example a list of string will be displayed as List<string> and not as List`1. diff --git a/Kyoo.CommonAPI/DatabaseContext.cs b/Kyoo.CommonAPI/DatabaseContext.cs index d92c208f..a795723e 100644 --- a/Kyoo.CommonAPI/DatabaseContext.cs +++ b/Kyoo.CommonAPI/DatabaseContext.cs @@ -64,6 +64,10 @@ namespace Kyoo /// All metadataIDs (ExternalIDs) of Kyoo. See . /// public DbSet MetadataIds { get; set; } + /// + /// The list of registered users. + /// + // public DbSet Users { get; set; } /// /// All people's role. See . diff --git a/Kyoo/Startup.cs b/Kyoo/Startup.cs index c172fd67..33eb9a67 100644 --- a/Kyoo/Startup.cs +++ b/Kyoo/Startup.cs @@ -124,6 +124,8 @@ namespace Kyoo return next(); }); app.UseResponseCompression(); + + _plugins.ConfigureAspnet(app); app.UseSpa(spa => { @@ -133,8 +135,6 @@ namespace Kyoo spa.UseAngularCliServer("start"); }); - _plugins.ConfigureAspnet(app); - app.UseEndpoints(endpoints => { endpoints.MapControllerRoute("Kyoo", "api/{controller=Home}/{action=Index}/{id?}"); diff --git a/Kyoo/settings.json b/Kyoo/settings.json index 246b84de..b521f8a8 100644 --- a/Kyoo/settings.json +++ b/Kyoo/settings.json @@ -33,7 +33,8 @@ "permissions": { "default": ["read", "play", "write", "admin"], "newUser": ["read", "play", "write", "admin"] - } + }, + "profilePicturePath": "users/" }, @@ -47,7 +48,6 @@ "transcodeTempPath": "cached/kyoo/transcode", "peoplePath": "people", "providerPath": "providers", - "profilePicturePath": "users/", "plugins": "plugins/", "regex": "(?:\\/(?.*?))?\\/(?.*?)(?: \\(\\d+\\))?\\/\\k(?: \\(\\d+\\))?(?:(?: S(?\\d+)E(?\\d+))| (?\\d+))?.*$", "subtitleRegex": "^(?.*)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$"