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 />
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 =>
// {
@ -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<User>()
// .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<AccountController>()
.AddSigninKeys(certificateOptions);
.AddDeveloperSigningCredential();
// .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 =>
{

View File

@ -17,9 +17,6 @@
<ItemGroup>
<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.AccessTokenValidation" Version="3.0.1" />
<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 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;
}
}
}
// 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;
// }
// }
// }

View File

@ -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<User> _userManager;
private readonly SignInManager<User> _signInManager;
private readonly IConfiguration _configuration;
private readonly string _picturePath;
public AccountController(UserManager<User> userManager,
SignInManager<User> siginInManager,
IConfiguration configuration)
{
_userManager = userManager;
_signInManager = siginInManager;
_picturePath = configuration.GetValue<string>("profilePicturePath");
_configuration = configuration;
if (!Path.IsPathRooted(_picturePath))
_picturePath = Path.GetFullPath(_picturePath);
}
[HttpPost("register")]
public async Task<IActionResult> 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<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.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(",");
// }
// }
// }

View File

@ -1,30 +1,23 @@
using System;
using System.Collections.Generic;
namespace Kyoo.Models
{
/// <summary>
/// An interface to represent a resource that can be retrieved from the database.
/// </summary>
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; }
/// <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 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);
}
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>
/// 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.

View File

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

View File

@ -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?}");

View File

@ -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": "(?:\\/(?<Collection>.*?))?\\/(?<Show>.*?)(?: \\(\\d+\\))?\\/\\k<Show>(?: \\(\\d+\\))?(?:(?: S(?<Season>\\d+)E(?<Episode>\\d+))| (?<Absolute>\\d+))?.*$",
"subtitleRegex": "^(?<Episode>.*)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$"