Finishing Login/Register handling

This commit is contained in:
Zoe Roux 2022-04-17 21:04:54 +02:00
parent 1a534d2325
commit 673cb48b75
No known key found for this signature in database
GPG Key ID: 54F19BB73170955D
8 changed files with 243 additions and 273 deletions

View File

@ -31,25 +31,30 @@ namespace Kyoo.Abstractions.Models.Utils
/// </summary> /// </summary>
public const int AlternativeRoute = 1; public const int AlternativeRoute = 1;
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users.
/// </summary>
public const string UsersGroup = "0:Users";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
/// </summary> /// </summary>
public const string ResourcesGroup = "0:Resources"; public const string ResourcesGroup = "1:Resources";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. /// A group name for <see cref="ApiDefinitionAttribute"/>.
/// It should be used for sub resources of kyoo that help define the main resources. /// It should be used for sub resources of kyoo that help define the main resources.
/// </summary> /// </summary>
public const string MetadataGroup = "1:Metadata"; public const string MetadataGroup = "2:Metadata";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
/// </summary> /// </summary>
public const string WatchGroup = "2:Watch"; public const string WatchGroup = "3:Watch";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </summary> /// </summary>
public const string AdminGroup = "3:Admin"; public const string AdminGroup = "4:Admin";
} }
} }

View File

@ -47,7 +47,7 @@ namespace Kyoo.Authentication
public string Name => "Authentication"; public string Name => "Authentication";
/// <inheritdoc /> /// <inheritdoc />
public string Description => "Enable an authentication/permission system for Kyoo (via Jwt or ApKeys)."; public string Description => "Enable an authentication/permission system for Kyoo (via Jwt or ApiKeys).";
/// <inheritdoc /> /// <inheritdoc />
public Dictionary<string, Type> Configuration => new() public Dictionary<string, Type> Configuration => new()
@ -62,7 +62,7 @@ namespace Kyoo.Authentication
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
/// <summary> /// <summary>
/// Create a new authentication module instance and use the given configuration and environment. /// Create a new authentication module instance and use the given configuration.
/// </summary> /// </summary>
/// <param name="configuration">The configuration to use</param> /// <param name="configuration">The configuration to use</param>
public AuthenticationModule(IConfiguration configuration) public AuthenticationModule(IConfiguration configuration)

View File

@ -22,33 +22,34 @@ using JetBrains.Annotations;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication; namespace Kyoo.Authentication
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public interface ITokenController
{ {
/// <summary> /// <summary>
/// Create a new access token for the given user. /// The service that controls jwt creation and validation.
/// </summary> /// </summary>
/// <param name="user">The user to create a token for.</param> public interface ITokenController
/// <param name="expireIn">When this token will expire.</param> {
/// <returns>A new, valid access token.</returns> /// <summary>
string CreateAccessToken([NotNull] User user, out TimeSpan expireIn); /// Create a new access token for the given user.
/// </summary>
/// <param name="user">The user to create a token for.</param>
/// <param name="expireIn">When this token will expire.</param>
/// <returns>A new, valid access token.</returns>
string CreateAccessToken([NotNull] User user, out TimeSpan expireIn);
/// <summary> /// <summary>
/// Create a new refresh token for the given user. /// Create a new refresh token for the given user.
/// </summary> /// </summary>
/// <param name="user">The user to create a token for.</param> /// <param name="user">The user to create a token for.</param>
/// <returns>A new, valid refresh token.</returns> /// <returns>A new, valid refresh token.</returns>
Task<string> CreateRefreshToken([NotNull] User user); Task<string> CreateRefreshToken([NotNull] User user);
/// <summary> /// <summary>
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
/// </summary> /// </summary>
/// <param name="refreshToken">The refresh token to validate.</param> /// <param name="refreshToken">The refresh token to validate.</param>
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception> /// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
/// <returns>The id of the token's user.</returns> /// <returns>The id of the token's user.</returns>
int GetRefreshTokenUserID(string refreshToken); int GetRefreshTokenUserID(string refreshToken);
}
} }

View File

@ -6,7 +6,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.12" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.12" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.2" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" /> <ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />

View File

@ -20,6 +20,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Utils; using Kyoo.Utils;
using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Models.DTO namespace Kyoo.Authentication.Models.DTO
{ {
@ -56,7 +57,7 @@ namespace Kyoo.Authentication.Models.DTO
{ {
Slug = Utility.ToSlug(Username), Slug = Utility.ToSlug(Username),
Username = Username, Username = Username,
Password = Password, Password = BCryptNet.HashPassword(Password),
Email = Email, Email = Email,
ExtraData = new Dictionary<string, string>() ExtraData = new Dictionary<string, string>()
}; };

View File

@ -20,39 +20,40 @@ using System;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Kyoo.Authentication; namespace Kyoo.Authentication
/// <summary>
/// A container representing the response of a login or token refresh.
/// </summary>
public class JwtToken
{ {
/// <summary> /// <summary>
/// The type of this token (always a Bearer). /// A container representing the response of a login or token refresh.
/// </summary> /// </summary>
[JsonProperty("token_token")] public class JwtToken
[JsonPropertyName("token_type")] {
public string TokenType => "Bearer";&é"bbbbR" /// <summary>
/// The type of this token (always a Bearer).
/// </summary>
[JsonProperty("token_token")]
[JsonPropertyName("token_type")]
public string TokenType => "Bearer";
/// <summary> /// <summary>
/// The access token used to authorize requests. /// The access token used to authorize requests.
/// </summary> /// </summary>
[JsonProperty("access_token")] [JsonProperty("access_token")]
[JsonPropertyName("access_token")] [JsonPropertyName("access_token")]
public string AccessToken { get; set; }p public string AccessToken { get; set; }
/// <summary> /// <summary>
/// The refresh token used to retrieve a new access/refresh token when the access token has expired. /// The refresh token used to retrieve a new access/refresh token when the access token has expired.
/// </summary> /// </summary>
[JsonProperty("refresh_token")] [JsonProperty("refresh_token")]
[JsonPropertyName("refresh_token")] [JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } public string RefreshToken { get; set; }
/// <summary> /// <summary>
/// The date when the access token will expire. After this date, the refresh token should be used to retrieve. /// When the access token will expire. After this tume, the refresh token should be used to retrieve.
/// a new token.cs /// a new token.cs
/// </summary> /// </summary>
[JsonProperty("expire_in")] [JsonProperty("expire_in")]
[JsonPropertyName("expire_in")] [JsonPropertyName("expire_in")]
public TimeSpan ExpireIn { get; set; } public TimeSpan ExpireIn { get; set; }
}
} }

View File

@ -1,212 +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;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Authentication;
using Kyoo.Authentication.Models.DTO;
using Kyoo.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using BCryptNet = BCrypt.Net.BCrypt;
namespace Amadeus.Server.Views.Auth;
/// <summary>
/// Sign in, Sign up or refresh tokens.
/// </summary>
[ApiController]
[Route("auth")]
public class AuthView : ControllerBase
{
/// <summary>
/// The repository used to check if the user exists.
/// </summary>
private readonly IUserRepository _users;
/// <summary>
/// The token generator.
/// </summary>
private readonly ITokenController _token;
/// <summary>
/// Create a new <see cref="AuthView"/>.
/// </summary>
/// <param name="users">The repository used to check if the user exists.</param>
/// <param name="token">The token generator.</param>
public AuthView(IUserRepository users, ITokenController token)
{
_users = users;
_token = token;
}
/// <summary>
/// Login.
/// </summary>
/// <remarks>
/// Login as a user and retrieve an access and a refresh token.
/// </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>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
User user = (await _users.GetAll()).FirstOrDefault(x => x.Username == request.Username);
if (user != null && BCryptNet.Verify(request.Password, user.Password))
{
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireDate),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireDate
};
}
return BadRequest(new { Message = "The user and password does not match." });
}
/// <summary>
/// Register.
/// </summary>
/// <remarks>
/// Register a new user and get a new access/refresh token for this new user.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The request is invalid.</response>
/// <response code="409">A user already exists with this username or email address.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
User user = request.ToUser();
user.Password = BCryptNet.HashPassword(request.Password);
try
{
await _users.Create(user);
}
catch (DuplicateField)
{
return Conflict(new { Message = "A user already exists with this username." });
}
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireDate),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireDate
};
}
/// <summary>
/// Refresh a token.
/// </summary>
/// <remarks>
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
/// The old refresh token should not be used anymore.
/// </remarks>
/// <param name="token">A valid refresh token.</param>
/// <returns>A new access and refresh token.</returns>
/// <response code="400">The given refresh token is invalid.</response>
[HttpGet("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
{
try
{
int userId = _token.GetRefreshTokenUserID(token);
User user = await _users.GetById(userId);
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireDate),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireDate
};
}
catch (ElementNotFound)
{
return BadRequest(new { Message = "Invalid refresh token." });
}
catch (SecurityTokenException ex)
{
return BadRequest(new { ex.Message });
}
}
[HttpGet("anilist")]
[ProducesResponseType(StatusCodes.Status302Found)]
public IActionResult AniListLogin([FromQuery] Uri redirectUrl, [FromServices] IOptions<AniListOptions> anilist)
{
Dictionary<string, string> query = new()
{
["client_id"] = anilist.Value.ClientID,
["redirect_uri"] = redirectUrl.ToString(),
["response_type"] = "code"
};
return Redirect($"https://anilist.co/api/v2/oauth/authorize{query.ToQueryString()}");
}
[HttpPost("link/anilist")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
public async Task<ActionResult<User>> AniListLink([FromQuery] string code, [FromServices] AniListService anilist)
{
// TODO prevent link if someone has already linked this account.
// TODO allow unlink.
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return BadRequest("Invalid access token");
return await anilist.LinkAccount(userID, code);
}
[HttpPost("login/anilist")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<JwtToken>> AniListLogin([FromQuery] string code, [FromServices] AniListService anilist)
{
User user = await anilist.Login(code);
return new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireIn),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireIn
};
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<User>> GetMe()
{
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return BadRequest("Invalid access token");
return await _users.GetById(userID);
}
}

View File

@ -0,0 +1,174 @@
// 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;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication.Models.DTO;
using Kyoo.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using static Kyoo.Abstractions.Models.Utils.Constants;
using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Views
{
/// <summary>
/// Sign in, Sign up or refresh tokens.
/// </summary>
[ApiController]
[Route("api/auth")]
[ApiDefinition("Authentication", Group = UsersGroup)]
public class AuthApi : ControllerBase
{
/// <summary>
/// The repository to handle users.
/// </summary>
private readonly IUserRepository _users;
/// <summary>
/// The token generator.
/// </summary>
private readonly ITokenController _token;
/// <summary>
/// Create a new <see cref="AuthApi"/>.
/// </summary>
/// <param name="users">The repository used to check if the user exists.</param>
/// <param name="token">The token generator.</param>
public AuthApi(IUserRepository users, ITokenController token)
{
_users = users;
_token = token;
}
/// <summary>
/// Login.
/// </summary>
/// <remarks>
/// Login as a user and retrieve an access and a refresh token.
/// </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>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
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 new JwtToken
{
AccessToken = _token.CreateAccessToken(user, out TimeSpan expireIn),
RefreshToken = await _token.CreateRefreshToken(user),
ExpireIn = expireIn
};
}
/// <summary>
/// Register.
/// </summary>
/// <remarks>
/// Register a new user and get a new access/refresh token for this new user.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The request is invalid.</response>
/// <response code="409">A user already exists with this username or email address.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
User user = request.ToUser();
try
{
await _users.Create(user);
}
catch (DuplicatedItemException)
{
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
};
}
/// <summary>
/// Refresh a token.
/// </summary>
/// <remarks>
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
/// </remarks>
/// <param name="token">A valid refresh token.</param>
/// <returns>A new access and refresh token.</returns>
/// <response code="400">The given refresh token is invalid.</response>
[HttpGet("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
{
try
{
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
};
}
catch (ItemNotFoundException)
{
return BadRequest(new RequestError("Invalid refresh token."));
}
catch (SecurityTokenException ex)
{
return BadRequest(new RequestError(ex.Message));
}
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<User>> GetMe()
{
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return BadRequest("Invalid access token");
return await _users.GetById(userID);
}
}
}