Create robot tests for login and register

This commit is contained in:
Zoe Roux 2022-05-08 00:40:36 +02:00
parent ad57df45d7
commit 14d23af0fd
No known key found for this signature in database
GPG Key ID: 54F19BB73170955D
20 changed files with 205 additions and 267 deletions

View File

@ -70,7 +70,6 @@ csharp_indent_case_contents = true
csharp_indent_switch_labels = true
# Modifiers
dotnet_style_readonly_field = true:suggestion
csharp_preferred_modifier_order = public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
# Naming style
dotnet_naming_symbols.privates.applicable_kinds = property,method,event,delegate

View File

@ -10,7 +10,7 @@ title: Build
To develop for Kyoo, you will need the **.NET 5.0 SDK**, **node** and **npm** for the webapp. If you want to build the transcoder, you will also need a cmake compatible environment.
## Building
To run the development server, simply open the .sln file with your favorite C# IDE (like Jetbrain's Rider or Visual Studio) and press run or you can use the CLI and use the ```dotnet run dotnet run -p src/Kyoo.Host.Console --launch-profile "Console"``` command.
To run the development server, simply open the .sln file with your favorite C# IDE (like Jetbrain's Rider or Visual Studio) and press run or you can use the CLI and use the ```dotnet run --project src/Kyoo.Host.Console --launch-profile "Console"``` command.
To pack the application, run the ```dotnet publish -c Release -o <build_path> Kyoo.Host.Console``` command. This will build the server, the webapp and the transcoder and output files in the <build_path> directory.
## Skipping parts

View File

@ -17,6 +17,7 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Collections.Generic;
using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models
{
@ -62,11 +63,13 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// The list of shows the user has finished.
/// </summary>
[SerializeIgnore]
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>
[SerializeIgnore]
public ICollection<WatchedEpisode> CurrentlyWatching { get; set; }
}
}

View File

@ -74,14 +74,16 @@ namespace Kyoo.Authentication
public void Configure(ContainerBuilder builder)
{
builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance();
builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance();
}
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
Uri publicUrl = _configuration.GetPublicUrl();
AuthenticationOption jwt = new();
_configuration.GetSection(AuthenticationOption.Path).Bind(jwt);
AuthenticationOption jwt = ConfigurationBinder.Get<AuthenticationOption>(
_configuration.GetSection(AuthenticationOption.Path)
);
// TODO handle direct-videos with bearers (probably add a cookie and a app.Use to translate that for videos)
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)

View File

@ -72,7 +72,7 @@ namespace Kyoo.Authentication
/// <summary>
/// The permission to validate.
/// </summary>
private readonly string _permission;
private readonly string? _permission;
/// <summary>
/// The kind of permission needed.
@ -133,7 +133,7 @@ namespace Kyoo.Authentication
/// <inheritdoc />
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
string permission = _permission;
string? permission = _permission;
Kind? kind = _kind;
if (permission == null || kind == null)

View File

@ -31,119 +31,120 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication;
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public class TokenController : ITokenController
namespace Kyoo.Authentication
{
/// <summary>
/// The options that this controller will use.
/// The service that controls jwt creation and validation.
/// </summary>
private readonly IOptions<AuthenticationOption> _options;
/// <summary>
/// The configuration used to retrieve the public URL of kyoo.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// Create a new <see cref="TokenController"/>.
/// </summary>
/// <param name="options">The options that this controller will use.</param>
/// <param name="configuration">The configuration used to retrieve the public URL of kyoo.</param>
public TokenController(IOptions<AuthenticationOption> options, IConfiguration configuration)
public class TokenController : ITokenController
{
_options = options;
_configuration = configuration;
}
/// <summary>
/// The options that this controller will use.
/// </summary>
private readonly IOptions<AuthenticationOption> _options;
/// <inheritdoc />
public string CreateAccessToken(User user, out TimeSpan expireIn)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
/// <summary>
/// The configuration used to retrieve the public URL of kyoo.
/// </summary>
private readonly IConfiguration _configuration;
expireIn = new TimeSpan(1, 0, 0);
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
string permissions = user.Permissions != null
? string.Join(',', user.Permissions)
: string.Empty;
List<Claim> claims = new()
/// <summary>
/// Create a new <see cref="TokenController"/>.
/// </summary>
/// <param name="options">The options that this controller will use.</param>
/// <param name="configuration">The configuration used to retrieve the public URL of kyoo.</param>
public TokenController(IOptions<AuthenticationOption> options, IConfiguration configuration)
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, permissions),
new Claim("type", "access")
};
if (user.Email != null)
claims.Add(new Claim(ClaimTypes.Email, user.Email));
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: claims,
expires: DateTime.UtcNow.Add(expireIn)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
_options = options;
_configuration = configuration;
}
/// <inheritdoc />
public Task<string> CreateRefreshToken(User user)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
/// <inheritdoc />
public string CreateAccessToken(User user, out TimeSpan expireIn)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: new[]
expireIn = new TimeSpan(1, 0, 0);
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
string permissions = user.Permissions != null
? string.Join(',', user.Permissions)
: string.Empty;
List<Claim> claims = new()
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)),
new Claim("guid", Guid.NewGuid().ToString()),
new Claim("type", "refresh")
},
expires: DateTime.UtcNow.AddYears(1)
);
// TODO refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
}
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, permissions),
new Claim("type", "access")
};
if (user.Email != null)
claims.Add(new Claim(ClaimTypes.Email, user.Email));
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: claims,
expires: DateTime.UtcNow.Add(expireIn)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <inheritdoc />
public int GetRefreshTokenUserID(string refreshToken)
{
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
JwtSecurityTokenHandler tokenHandler = new();
ClaimsPrincipal principal;
try
/// <inheritdoc />
public Task<string> CreateRefreshToken(User user)
{
principal = tokenHandler.ValidateToken(refreshToken, new TokenValidationParameters
if (user == null)
throw new ArgumentNullException(nameof(user));
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: new[]
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)),
new Claim("guid", Guid.NewGuid().ToString()),
new Claim("type", "refresh")
},
expires: DateTime.UtcNow.AddYears(1)
);
// TODO refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
}
/// <inheritdoc />
public int GetRefreshTokenUserID(string refreshToken)
{
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
JwtSecurityTokenHandler tokenHandler = new();
ClaimsPrincipal principal;
try
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = _configuration.GetPublicUrl().ToString(),
ValidAudience = _configuration.GetPublicUrl().ToString(),
IssuerSigningKey = key
}, out SecurityToken _);
}
catch (Exception ex)
{
throw new SecurityTokenException(ex.Message);
}
principal = tokenHandler.ValidateToken(refreshToken, new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = _configuration.GetPublicUrl().ToString(),
ValidAudience = _configuration.GetPublicUrl().ToString(),
IssuerSigningKey = key
}, out SecurityToken _);
}
catch (Exception ex)
{
throw new SecurityTokenException(ex.Message);
}
if (principal.Claims.First(x => x.Type == "type").Value != "refresh")
throw new SecurityTokenException("Invalid token type. The token should be a refresh token.");
Claim identifier = principal.Claims.First(x => x.Type == ClaimTypes.NameIdentifier);
if (int.TryParse(identifier.Value, out int id))
return id;
throw new SecurityTokenException("Token not associated to any user.");
if (principal.Claims.First(x => x.Type == "type").Value != "refresh")
throw new SecurityTokenException("Invalid token type. The token should be a refresh token.");
Claim identifier = principal.Claims.First(x => x.Type == ClaimTypes.NameIdentifier);
if (int.TryParse(identifier.Value, out int id))
return id;
throw new SecurityTokenException("Token not associated to any user.");
}
}
}

View File

@ -16,6 +16,7 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
@ -34,7 +35,8 @@ namespace Kyoo.Authentication
/// <returns>The list of permissions</returns>
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
{
return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',');
return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',')
?? Array.Empty<string>();
}
}
}

View File

@ -2,6 +2,7 @@
<PropertyGroup>
<LoginRoot>../Kyoo.WebLogin/</LoginRoot>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>

View File

@ -1,46 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A model only used on account update requests.
/// </summary>
public class AccountUpdateRequest
{
/// <summary>
/// The new email address of the user
/// </summary>
[EmailAddress(ErrorMessage = "The email is invalid.")]
public string Email { get; set; }
/// <summary>
/// The new username of the user.
/// </summary>
[MinLength(4, ErrorMessage = "The username must have at least 4 characters")]
public string Username { get; set; }
/// <summary>
/// The picture icon.
/// </summary>
public IFormFile Picture { get; set; }
}
}

View File

@ -34,13 +34,14 @@ namespace Kyoo.Authentication.Models.DTO
public string Password { get; set; }
/// <summary>
/// Should the user stay logged in? If true a cookie will be put.
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
/// </summary>
public bool StayLoggedIn { get; set; }
/// <summary>
/// The return url of the login flow.
/// </summary>
public string ReturnURL { get; set; }
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public LoginRequest(string username, string password)
{
Username = username;
Password = password;
}
}
}

View File

@ -1,36 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
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

@ -1,41 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A one time access token
/// </summary>
public class OtacResponse
{
/// <summary>
/// The One Time Access Token that allow one to connect to an account without typing a password or without
/// any kind of verification. This is valid only one time and only for a short period of time.
/// </summary>
public string OTAC { get; set; }
/// <summary>
/// Create a new <see cref="OtacResponse"/>.
/// </summary>
/// <param name="otac">The one time access token.</param>
public OtacResponse(string otac)
{
OTAC = otac;
}
}
}

View File

@ -44,9 +44,22 @@ namespace Kyoo.Authentication.Models.DTO
/// <summary>
/// The user's password.
/// </summary>
[MinLength(8, ErrorMessage = "The password must have at least {1} characters")]
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
/// </summary>
/// <param name="email">The user email address.</param>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public RegisterRequest(string email, string username, string password)
{
Email = email;
Username = username;
Password = password;
}
/// <summary>
/// Convert this register request to a new <see cref="User"/> class.
/// </summary>

View File

@ -55,5 +55,18 @@ namespace Kyoo.Authentication
[JsonProperty("expire_in")]
[JsonPropertyName("expire_in")]
public TimeSpan ExpireIn { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="JwtToken"/> class.
/// </summary>
/// <param name="accessToken">The access token used to authorize requests.</param>
/// <param name="refreshToken">The refresh token to retrieve a new access token.</param>
/// <param name="expireIn">When the access token will expire.</param>
public JwtToken(string accessToken, string refreshToken, TimeSpan expireIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpireIn = expireIn;
}
}
}

View File

@ -28,19 +28,24 @@ namespace Kyoo.Authentication.Models
/// </summary>
public const string Path = "authentication";
/// <summary>
/// The default jwt secret.
/// </summary>
public const string DefaultSecret = "jwt-secret";
/// <summary>
/// The secret used to encrypt the jwt.
/// </summary>
public string Secret { get; set; }
public string Secret { get; set; } = DefaultSecret;
/// <summary>
/// Options for permissions
/// </summary>
public PermissionOption Permissions { get; set; }
public PermissionOption Permissions { get; set; } = new();
/// <summary>
/// Root path of user's profile pictures.
/// </summary>
public string ProfilePicturePath { get; set; }
public string ProfilePicturePath { get; set; } = "users/";
}
}

View File

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

View File

@ -63,6 +63,16 @@ namespace Kyoo.Authentication.Views
_token = token;
}
/// <summary>
/// Create a new Forbidden result from an object.
/// </summary>
/// <param name="value">The json value to output on the response.</param>
/// <returns>A new forbidden result with the given json object.</returns>
public static ObjectResult Forbid(object value)
{
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
}
/// <summary>
/// Login.
/// </summary>
@ -71,7 +81,7 @@ namespace Kyoo.Authentication.Views
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The user and password does not match.</response>
/// <response code="403">The user and password does not match.</response>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
@ -79,14 +89,13 @@ namespace Kyoo.Authentication.Views
{
User user = await _users.GetOrDefault(x => x.Username == request.Username);
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return BadRequest(new RequestError("The user and password does not match."));
return Forbid(new RequestError("The user and password does not match."));
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireIn),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireIn
};
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
@ -115,12 +124,11 @@ namespace Kyoo.Authentication.Views
return Conflict(new RequestError("A user already exists with this username."));
}
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireIn),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireIn
};
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
@ -141,12 +149,11 @@ namespace Kyoo.Authentication.Views
{
int userId = _token.GetRefreshTokenUserID(token);
User user = await _users.Get(userId);
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireIn),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireIn
};
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
catch (ItemNotFoundException)
{
@ -176,5 +183,7 @@ namespace Kyoo.Authentication.Views
return Forbid();
return await _users.Get(userID);
}
// TODO: Add a put to edit the current user.
}
}

View File

@ -61,8 +61,8 @@
"authentication": {
"permissions": {
"default": ["overall.read", "overall.write", "overall.create", "overall.delete", "admin.read", "admin.write"],
"newUser": ["overall.read", "overall.write", "overall.create", "overall.delete", "admin.read", "admin.write"]
"default": ["overall.read", "overall.write"],
"newUser": ["overall.read", "overall.write"]
},
"profilePicturePath": "users/",
"secret": "jwt-secret"

View File

@ -1,7 +1,18 @@
*** Settings ***
Documentation Tests of the /auth route.
... Ensures that the user can authenticate on kyoo.
Resource ${RESOURCES}/rest.resource
Resource ../rest.resource
*** Test Cases ***
BadAccount
[Documentation] Login fails if user does not exist
POST /auth/login {"username": "toto", "password": "tata"}
Output
Integer response status 403
Register
[Documentation] Create a new user and login in it
POST /auth/register {"username": "toto", "password": "tata", "email": "mail@kyoo.moe"}
Output
Integer response status 403

View File

@ -1,3 +1,4 @@
*** Settings ***
Documentation Common things to handle rest requests
Library REST
Documentation Common things to handle rest requests
Library REST http://localhost:5000/api