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>
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>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
/// </summary>
public const string ResourcesGroup = "0:Resources";
public const string ResourcesGroup = "1:Resources";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>.
/// It should be used for sub resources of kyoo that help define the main resources.
/// </summary>
public const string MetadataGroup = "1:Metadata";
public const string MetadataGroup = "2:Metadata";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
/// </summary>
public const string WatchGroup = "2:Watch";
public const string WatchGroup = "3:Watch";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </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";
/// <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 />
public Dictionary<string, Type> Configuration => new()
@ -62,7 +62,7 @@ namespace Kyoo.Authentication
private readonly IConfiguration _configuration;
/// <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>
/// <param name="configuration">The configuration to use</param>
public AuthenticationModule(IConfiguration configuration)

View File

@ -22,8 +22,8 @@ using JetBrains.Annotations;
using Kyoo.Abstractions.Models;
using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication;
namespace Kyoo.Authentication
{
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
@ -52,3 +52,4 @@ public interface ITokenController
/// <returns>The id of the token's user.</returns>
int GetRefreshTokenUserID(string refreshToken);
}
}

View File

@ -6,7 +6,7 @@
<ItemGroup>
<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" />
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />

View File

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

View File

@ -20,8 +20,8 @@ using System;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace Kyoo.Authentication;
namespace Kyoo.Authentication
{
/// <summary>
/// A container representing the response of a login or token refresh.
/// </summary>
@ -32,14 +32,14 @@ public class JwtToken
/// </summary>
[JsonProperty("token_token")]
[JsonPropertyName("token_type")]
public string TokenType => "Bearer";&é"bbbbR"
public string TokenType => "Bearer";
/// <summary>
/// The access token used to authorize requests.
/// </summary>
[JsonProperty("access_token")]
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }p
public string AccessToken { get; set; }
/// <summary>
/// The refresh token used to retrieve a new access/refresh token when the access token has expired.
@ -49,10 +49,11 @@ public class JwtToken
public string RefreshToken { get; set; }
/// <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
/// </summary>
[JsonProperty("expire_in")]
[JsonPropertyName("expire_in")]
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);
}
}
}