Starting to rework authentication

This commit is contained in:
Zoe Roux 2021-05-06 01:40:19 +02:00
parent da699c096d
commit 0f2bea9bc4
11 changed files with 323 additions and 315 deletions

View File

@ -64,7 +64,7 @@ namespace Kyoo.Authentication
/// <inheritdoc /> /// <inheritdoc />
public void Configure(IServiceCollection services, ICollection<Type> availableTypes) public void Configure(IServiceCollection services, ICollection<Type> availableTypes)
{ {
string publicUrl = _configuration.GetValue<string>("public_url"); string publicUrl = _configuration.GetValue<string>("public_url").TrimEnd('/');
// services.AddDbContext<IdentityDatabase>(options => // services.AddDbContext<IdentityDatabase>(options =>
// { // {
@ -86,9 +86,9 @@ namespace Kyoo.Authentication
services.AddIdentityServer(options => services.AddIdentityServer(options =>
{ {
options.IssuerUri = publicUrl; options.IssuerUri = publicUrl;
options.UserInteraction.LoginUrl = publicUrl + "login"; options.UserInteraction.LoginUrl = $"{publicUrl}/login";
options.UserInteraction.ErrorUrl = publicUrl + "error"; options.UserInteraction.ErrorUrl = $"{publicUrl}/error";
options.UserInteraction.LogoutUrl = publicUrl + "logout"; options.UserInteraction.LogoutUrl = $"{publicUrl}/logout";
}) })
// .AddAspNetIdentity<User>() // .AddAspNetIdentity<User>()
// .AddConfigurationStore(options => // .AddConfigurationStore(options =>
@ -105,11 +105,13 @@ namespace Kyoo.Authentication
// options.EnableTokenCleanup = true; // options.EnableTokenCleanup = true;
// }) // })
.AddInMemoryIdentityResources(IdentityContext.GetIdentityResources()) .AddInMemoryIdentityResources(IdentityContext.GetIdentityResources())
.AddInMemoryApiScopes(IdentityContext.GetScopes())
.AddInMemoryApiResources(IdentityContext.GetApis()) .AddInMemoryApiResources(IdentityContext.GetApis())
.AddInMemoryClients(IdentityContext.GetClients()) .AddInMemoryClients(IdentityContext.GetClients())
// .AddProfileService<AccountController>() .AddDeveloperSigningCredential();
.AddSigninKeys(certificateOptions); // .AddProfileService<AccountApi>()
// .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 => services.AddAuthentication(o =>
{ {

View File

@ -17,9 +17,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="IdentityServer4" Version="4.1.2" /> <PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
<PackageReference Include="IdentityServer4.EntityFramework" Version="4.1.2" />
<PackageReference Include="IdentityServer4.EntityFramework.Storage" Version="4.1.2" />
<PackageReference Include="IdentityServer4.Storage" Version="4.1.2" /> <PackageReference Include="IdentityServer4.Storage" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Portable.BouncyCastle" Version="1.8.10" /> <PackageReference Include="Portable.BouncyCastle" Version="1.8.10" />

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using Kyoo.Models;
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A model only used on register requests.
/// </summary>
public class RegisterRequest
{
/// <summary>
/// The user email address
/// </summary>
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// The user's username.
/// </summary>
[MinLength(4)]
public string Username { get; set; }
/// <summary>
/// The user's password.
/// </summary>
[MinLength(8)]
public string Password { get; set; }
/// <summary>
/// Convert this register request to a new <see cref="User"/> class.
/// </summary>
/// <returns></returns>
public User ToUser()
{
return new()
{
Slug = Utility.ToSlug(Username),
Username = Username,
Password = Password,
Email = Email
};
}
}
}

View File

@ -1,22 +1,22 @@
using System; // using System;
using IdentityModel; // using IdentityModel;
using Microsoft.AspNetCore.Identity; // using Microsoft.AspNetCore.Identity;
//
namespace Kyoo.Models // namespace Kyoo.Models
{ // {
public class User : IdentityUser // public class User : IdentityUser
{ // {
public string OTAC { get; set; } // public string OTAC { get; set; }
public DateTime? OTACExpires { get; set; } // public DateTime? OTACExpires { get; set; }
//
public string GenerateOTAC(TimeSpan validFor) // public string GenerateOTAC(TimeSpan validFor)
{ // {
string otac = CryptoRandom.CreateUniqueId(); // string otac = CryptoRandom.CreateUniqueId();
string hashed = otac; // TODO should add a good hashing here. // string hashed = otac; // TODO should add a good hashing here.
//
OTAC = hashed; // OTAC = hashed;
OTACExpires = DateTime.UtcNow.Add(validFor); // OTACExpires = DateTime.UtcNow.Add(validFor);
return otac; // return otac;
} // }
} // }
} // }

View File

@ -1,191 +1,183 @@
using System; // using System;
using System.Collections.Generic; // using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; // using System.IO;
using System.IO; // using System.Linq;
using System.Linq; // using System.Security.Claims;
using System.Security.Claims; // using System.Threading.Tasks;
using System.Threading.Tasks; // using IdentityServer4.Models;
using IdentityServer4.Models; // using IdentityServer4.Services;
using IdentityServer4.Services; // using Kyoo.Authentication.Models.DTO;
using Kyoo.Models; // using Kyoo.Models;
using Microsoft.AspNetCore.Authorization; // using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; // using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; // using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; // using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; // using Microsoft.Extensions.Configuration;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; // using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
//
namespace Kyoo.Api // namespace Kyoo.Authentication.Views
{ // {
public class RegisterRequest // public class LoginRequest
{ // {
public string Email { get; set; } // public string Username { get; set; }
public string Username { get; set; } // public string Password { get; set; }
public string Password { get; set; } // public bool StayLoggedIn { get; set; }
} // }
//
public class LoginRequest // public class OtacRequest
{ // {
public string Username { get; set; } // public string Otac { get; set; }
public string Password { get; set; } // public bool StayLoggedIn { get; set; }
public bool StayLoggedIn { get; set; } // }
} //
// public class AccountData
public class OtacRequest // {
{ // [FromForm(Name = "email")]
public string Otac { get; set; } // public string Email { get; set; }
public bool StayLoggedIn { get; set; } // [FromForm(Name = "username")]
} // public string Username { get; set; }
// [FromForm(Name = "picture")]
public class AccountData // public IFormFile Picture { get; set; }
{ // }
[FromForm(Name = "email")] //
public string Email { get; set; } //
[FromForm(Name = "username")] // /// <summary>
public string Username { get; set; } // /// The class responsible for login, logout, permissions and claims of a user.
[FromForm(Name = "picture")] // /// </summary>
public IFormFile Picture { get; set; } // [Route("api/account")]
} // [Route("api/accounts")]
// [ApiController]
// public class AccountApi : Controller, IProfileService
[Route("api/[controller]")] // {
[ApiController] // private readonly UserManager<User> _userManager;
public class AccountController : Controller, IProfileService // private readonly SignInManager<User> _signInManager;
{ // private readonly IConfiguration _configuration;
private readonly UserManager<User> _userManager; // private readonly string _picturePath;
private readonly SignInManager<User> _signInManager; //
private readonly IConfiguration _configuration; // // TODO find how SignInManager & UserManager are implement and check if they can be used or not.
private readonly string _picturePath; // public AccountApi(UserManager<User> userManager,
// SignInManager<User> signInManager,
public AccountController(UserManager<User> userManager, // IConfiguration configuration)
SignInManager<User> siginInManager, // {
IConfiguration configuration) // _userManager = userManager;
{ // _signInManager = signInManager;
_userManager = userManager; // _picturePath = configuration.GetValue<string>("profilePicturePath");
_signInManager = siginInManager; // _configuration = configuration;
_picturePath = configuration.GetValue<string>("profilePicturePath"); // if (!Path.IsPathRooted(_picturePath))
_configuration = configuration; // _picturePath = Path.GetFullPath(_picturePath);
if (!Path.IsPathRooted(_picturePath)) // }
_picturePath = Path.GetFullPath(_picturePath); //
} // [HttpPost("register")]
// public async Task<IActionResult> Register([FromBody] RegisterRequest request)
[HttpPost("register")] // {
public async Task<IActionResult> Register([FromBody] RegisterRequest user) // User user = request.ToUser();
{ // IdentityResult result = await _userManager.CreateAsync(user, user.Password);
if (!ModelState.IsValid) // if (!result.Succeeded)
return BadRequest(user); // return BadRequest(result.Errors);
if (user.Username.Length < 4) // string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1));
return BadRequest(new[] {new {code = "username", description = "Username must be at least 4 characters."}}); // await _userManager.UpdateAsync(account);
if (!new EmailAddressAttribute().IsValid(user.Email)) // await _userManager.AddClaimAsync(account, new Claim(
return BadRequest(new[] {new {code = "email", description = "Email must be valid."}}); // "permissions",
User account = new() {UserName = user.Username, Email = user.Email}; // _configuration.GetValue<string>("newUserPermissions")));
IdentityResult result = await _userManager.CreateAsync(account, user.Password); // return Ok(new {otac});
if (!result.Succeeded) // }
return BadRequest(result.Errors); //
string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1)); // [HttpPost("login")]
await _userManager.UpdateAsync(account); // public async Task<IActionResult> Login([FromBody] LoginRequest login)
await _userManager.AddClaimAsync(account, new Claim( // {
"permissions", // if (!ModelState.IsValid)
_configuration.GetValue<string>("newUserPermissions"))); // return BadRequest(login);
return Ok(new {otac}); // SignInResult result = await _signInManager
} // .PasswordSignInAsync(login.Username, login.Password, login.StayLoggedIn, false);
// if (result.Succeeded)
[HttpPost("login")] // return Ok();
public async Task<IActionResult> Login([FromBody] LoginRequest login) // return BadRequest(new [] { new {code = "InvalidCredentials", description = "Invalid username/password"}});
{ // }
if (!ModelState.IsValid) //
return BadRequest(login); // [HttpPost("otac-login")]
SignInResult result = await _signInManager // public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
.PasswordSignInAsync(login.Username, login.Password, login.StayLoggedIn, false); // {
if (result.Succeeded) // if (!ModelState.IsValid)
return Ok(); // return BadRequest(otac);
return BadRequest(new [] { new {code = "InvalidCredentials", description = "Invalid username/password"}}); // 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."}});
[HttpPost("otac-login")] // if (user.OTACExpires <= DateTime.UtcNow)
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac) // return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}});
{ // await _signInManager.SignInAsync(user, otac.StayLoggedIn);
if (!ModelState.IsValid) // return Ok();
return BadRequest(otac); // }
User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac); //
if (user == null) // [HttpGet("logout")]
return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}}); // [Authorize]
if (user.OTACExpires <= DateTime.UtcNow) // public async Task<IActionResult> Logout()
return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}}); // {
await _signInManager.SignInAsync(user, otac.StayLoggedIn); // await _signInManager.SignOutAsync();
return Ok(); // return Ok();
} // }
//
[HttpGet("logout")] // public async Task GetProfileDataAsync(ProfileDataRequestContext context)
[Authorize] // {
public async Task<IActionResult> Logout() // User user = await _userManager.GetUserAsync(context.Subject);
{ // if (user != null)
await _signInManager.SignOutAsync(); // {
return Ok(); // List<Claim> claims = new()
} // {
// new Claim("email", user.Email),
public async Task GetProfileDataAsync(ProfileDataRequestContext context) // new Claim("username", user.UserName),
{ // new Claim("picture", $"api/account/picture/{user.UserName}")
User user = await _userManager.GetUserAsync(context.Subject); // };
if (user != null) //
{ // Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions");
List<Claim> claims = new() // if (perms != null)
{ // claims.Add(perms);
new Claim("email", user.Email), //
new Claim("username", user.UserName), // context.IssuedClaims.AddRange(claims);
new Claim("picture", $"api/account/picture/{user.UserName}") // }
}; // }
//
Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions"); // public async Task IsActiveAsync(IsActiveContext context)
if (perms != null) // {
claims.Add(perms); // User user = await _userManager.GetUserAsync(context.Subject);
// context.IsActive = user != null;
context.IssuedClaims.AddRange(claims); // }
} //
} // [HttpGet("picture/{username}")]
// public async Task<IActionResult> GetPicture(string username)
public async Task IsActiveAsync(IsActiveContext context) // {
{ // User user = await _userManager.FindByNameAsync(username);
User user = await _userManager.GetUserAsync(context.Subject); // if (user == null)
context.IsActive = user != null; // return BadRequest();
} // string path = Path.Combine(_picturePath, user.Id);
// if (!System.IO.File.Exists(path))
[HttpGet("picture/{username}")] // return NotFound();
public async Task<IActionResult> GetPicture(string username) // return new PhysicalFileResult(path, "image/png");
{ // }
User user = await _userManager.FindByNameAsync(username); //
if (user == null) // [HttpPost("update")]
return BadRequest(); // [Authorize]
string path = Path.Combine(_picturePath, user.Id); // public async Task<IActionResult> Update([FromForm] AccountData data)
if (!System.IO.File.Exists(path)) // {
return NotFound(); // User user = await _userManager.GetUserAsync(HttpContext.User);
return new PhysicalFileResult(path, "image/png"); //
} // if (!string.IsNullOrEmpty(data.Email))
// user.Email = data.Email;
[HttpPost("update")] // if (!string.IsNullOrEmpty(data.Username))
[Authorize] // user.UserName = data.Username;
public async Task<IActionResult> Update([FromForm] AccountData data) // if (data.Picture?.Length > 0)
{ // {
User user = await _userManager.GetUserAsync(HttpContext.User); // string path = Path.Combine(_picturePath, user.Id);
// await using FileStream file = System.IO.File.Create(path);
if (!string.IsNullOrEmpty(data.Email)) // await data.Picture.CopyToAsync(file);
user.Email = data.Email; // }
if (!string.IsNullOrEmpty(data.Username)) // await _userManager.UpdateAsync(user);
user.UserName = data.Username; // return Ok();
if (data.Picture?.Length > 0) // }
{ //
string path = Path.Combine(_picturePath, user.Id); // [HttpGet("default-permissions")]
await using FileStream file = System.IO.File.Create(path); // public ActionResult<IEnumerable<string>> GetDefaultPermissions()
await data.Picture.CopyToAsync(file); // {
} // return _configuration.GetValue<string>("defaultPermissions").Split(",");
await _userManager.UpdateAsync(user); // }
return Ok(); // }
} // }
[HttpGet("default-permissions")]
public ActionResult<IEnumerable<string>> GetDefaultPermissions()
{
return _configuration.GetValue<string>("defaultPermissions").Split(",");
}
}
}

View File

@ -1,30 +1,23 @@
using System;
using System.Collections.Generic;
namespace Kyoo.Models namespace Kyoo.Models
{ {
/// <summary>
/// An interface to represent a resource that can be retrieved from the database.
/// </summary>
public interface IResource public interface IResource
{ {
/// <summary>
/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
/// </summary>
public int ID { get; set; } public int ID { get; set; }
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// There is no setter for a slug since it can be computed from other fields.
/// For example, a season slug is {ShowSlug}-s{SeasonNumber}.
/// </remarks>
public string Slug { get; } public string Slug { get; }
} }
public class ResourceComparer<T> : IEqualityComparer<T> 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);
}
}
} }

View File

@ -0,0 +1,41 @@
using System.Collections.Generic;
namespace Kyoo.Models
{
/// <summary>
/// A single user of the app.
/// </summary>
public class User : IResource
{
/// <inheritdoc />
public int ID { get; set; }
/// <inheritdoc />
public string Slug { get; set; }
/// <summary>
/// A username displayed to the user.
/// </summary>
public string Username { get; set; }
/// <summary>
/// The user email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
/// </summary>
public string Password { get; set; }
/// <summary>
/// The list of shows the user has finished.
/// </summary>
public ICollection<Show> Watched { get; set; }
/// <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; }
}
}

View File

@ -712,72 +712,6 @@ namespace Kyoo
}, TaskContinuationOptions.ExecuteSynchronously); }, TaskContinuationOptions.ExecuteSynchronously);
} }
public static Expression<Func<T, bool>> ResourceEquals<T>(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<T, bool> ResourceEqualsFunc<T>(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<IResource>(), ens.Cast<IResource>());
return RunGenericMethod<bool>(typeof(Enumerable), "SequenceEqual", type, first, second);
}
public static bool ResourceEquals<T>([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<T>([CanBeNull] IEnumerable<T> first, [CanBeNull] IEnumerable<T> second)
where T : IResource
{
if (ReferenceEquals(first, second))
return true;
if (first == null || second == null)
return false;
return first.SequenceEqual(second, new ResourceComparer<T>());
}
public static bool LinkEquals<T>([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;
}
/// <summary> /// <summary>
/// Get a friendly type name (supporting generics) /// Get a friendly type name (supporting generics)
/// For example a list of string will be displayed as List&lt;string&gt; and not as List`1. /// For example a list of string will be displayed as List&lt;string&gt; and not as List`1.

View File

@ -64,6 +64,10 @@ namespace Kyoo
/// All metadataIDs (ExternalIDs) of Kyoo. See <see cref="MetadataID"/>. /// All metadataIDs (ExternalIDs) of Kyoo. See <see cref="MetadataID"/>.
/// </summary> /// </summary>
public DbSet<MetadataID> MetadataIds { get; set; } public DbSet<MetadataID> MetadataIds { get; set; }
/// <summary>
/// The list of registered users.
/// </summary>
// public DbSet<User> Users { get; set; }
/// <summary> /// <summary>
/// All people's role. See <see cref="PeopleRole"/>. /// All people's role. See <see cref="PeopleRole"/>.

View File

@ -124,6 +124,8 @@ namespace Kyoo
return next(); return next();
}); });
app.UseResponseCompression(); app.UseResponseCompression();
_plugins.ConfigureAspnet(app);
app.UseSpa(spa => app.UseSpa(spa =>
{ {
@ -133,8 +135,6 @@ namespace Kyoo
spa.UseAngularCliServer("start"); spa.UseAngularCliServer("start");
}); });
_plugins.ConfigureAspnet(app);
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
endpoints.MapControllerRoute("Kyoo", "api/{controller=Home}/{action=Index}/{id?}"); endpoints.MapControllerRoute("Kyoo", "api/{controller=Home}/{action=Index}/{id?}");

View File

@ -33,7 +33,8 @@
"permissions": { "permissions": {
"default": ["read", "play", "write", "admin"], "default": ["read", "play", "write", "admin"],
"newUser": ["read", "play", "write", "admin"] "newUser": ["read", "play", "write", "admin"]
} },
"profilePicturePath": "users/"
}, },
@ -47,7 +48,6 @@
"transcodeTempPath": "cached/kyoo/transcode", "transcodeTempPath": "cached/kyoo/transcode",
"peoplePath": "people", "peoplePath": "people",
"providerPath": "providers", "providerPath": "providers",
"profilePicturePath": "users/",
"plugins": "plugins/", "plugins": "plugins/",
"regex": "(?:\\/(?<Collection>.*?))?\\/(?<Show>.*?)(?: \\(\\d+\\))?\\/\\k<Show>(?: \\(\\d+\\))?(?:(?: S(?<Season>\\d+)E(?<Episode>\\d+))| (?<Absolute>\\d+))?.*$", "regex": "(?:\\/(?<Collection>.*?))?\\/(?<Show>.*?)(?: \\(\\d+\\))?\\/\\k<Show>(?: \\(\\d+\\))?(?:(?: S(?<Season>\\d+)E(?<Episode>\\d+))| (?<Absolute>\\d+))?.*$",
"subtitleRegex": "^(?<Episode>.*)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$" "subtitleRegex": "^(?<Episode>.*)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$"