diff --git a/src/Kyoo.Authentication/Controllers/PermissionValidator.cs b/src/Kyoo.Authentication/Controllers/PermissionValidator.cs index 0b4f8768..370a02f3 100644 --- a/src/Kyoo.Authentication/Controllers/PermissionValidator.cs +++ b/src/Kyoo.Authentication/Controllers/PermissionValidator.cs @@ -22,12 +22,14 @@ using System.Linq; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; using Kyoo.Authentication.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Kyoo.Authentication @@ -173,15 +175,26 @@ namespace Kyoo.Authentication { ICollection permissions = res.Principal.GetPermissions(); if (permissions.All(x => x != permStr && x != overallStr)) - context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + context.Result = _ErrorResult($"Missing permission {permStr} or {overallStr}", StatusCodes.Status403Forbidden); } else { ICollection permissions = _options.CurrentValue.Default ?? Array.Empty(); if (res.Failure != null || permissions.All(x => x != permStr && x != overallStr)) - context.Result = new StatusCodeResult(StatusCodes.Status401Unauthorized); + context.Result = _ErrorResult($"Unlogged user does not have permission {permStr} or {overallStr}", StatusCodes.Status401Unauthorized); } } } + + /// + /// Create a new action result with the given error message and error code. + /// + /// The error message. + /// The status code of the error. + /// The resulting error action. + private static IActionResult _ErrorResult(string error, int code) + { + return new ObjectResult(new RequestError(error)) { StatusCode = code }; + } } } diff --git a/src/Kyoo.Authentication/Controllers/TokenController.cs b/src/Kyoo.Authentication/Controllers/TokenController.cs index d006cf4e..1a127e97 100644 --- a/src/Kyoo.Authentication/Controllers/TokenController.cs +++ b/src/Kyoo.Authentication/Controllers/TokenController.cs @@ -71,13 +71,13 @@ namespace Kyoo.Authentication : string.Empty; List claims = new() { - new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)), - new Claim(ClaimTypes.Name, user.Username), - new Claim(ClaimTypes.Role, permissions), - new Claim("type", "access") + new Claim(Claims.Id, user.ID.ToString(CultureInfo.InvariantCulture)), + new Claim(Claims.Name, user.Username), + new Claim(Claims.Permissions, permissions), + new Claim(Claims.Type, "access") }; if (user.Email != null) - claims.Add(new Claim(ClaimTypes.Email, user.Email)); + claims.Add(new Claim(Claims.Email, user.Email)); JwtSecurityToken token = new( signingCredentials: credential, issuer: _configuration.GetPublicUrl().ToString(), @@ -99,9 +99,9 @@ namespace Kyoo.Authentication 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") + new Claim(Claims.Id, user.ID.ToString(CultureInfo.InvariantCulture)), + new Claim(Claims.Guid, Guid.NewGuid().ToString()), + new Claim(Claims.Type, "refresh") }, expires: DateTime.UtcNow.AddYears(1) ); @@ -133,9 +133,9 @@ namespace Kyoo.Authentication throw new SecurityTokenException(ex.Message); } - if (principal.Claims.First(x => x.Type == "type").Value != "refresh") + if (principal.Claims.First(x => x.Type == Claims.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); + Claim identifier = principal.Claims.First(x => x.Type == Claims.Id); if (int.TryParse(identifier.Value, out int id)) return id; throw new SecurityTokenException("Token not associated to any user."); diff --git a/src/Kyoo.Authentication/Extensions.cs b/src/Kyoo.Authentication/Extensions.cs index 369128d6..8062b2e8 100644 --- a/src/Kyoo.Authentication/Extensions.cs +++ b/src/Kyoo.Authentication/Extensions.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using Kyoo.Authentication.Models; namespace Kyoo.Authentication { @@ -35,7 +36,7 @@ namespace Kyoo.Authentication /// The list of permissions public static ICollection GetPermissions(this ClaimsPrincipal user) { - return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',') + return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') ?? Array.Empty(); } } diff --git a/src/Kyoo.Authentication/Models/Claims.cs b/src/Kyoo.Authentication/Models/Claims.cs new file mode 100644 index 00000000..bf2256aa --- /dev/null +++ b/src/Kyoo.Authentication/Models/Claims.cs @@ -0,0 +1,56 @@ +// 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 . + +namespace Kyoo.Authentication.Models +{ + /// + /// List of well known claims of kyoo + /// + public static class Claims + { + /// + /// The id of the user + /// + public static string Id => "id"; + + /// + /// The name of the user + /// + public static string Name => "name"; + + /// + /// The email of the user. + /// + public static string Email => "email"; + + /// + /// The list of permissions that the user has. + /// + public static string Permissions => "permissions"; + + /// + /// The type of the token (either "access" or "refresh"). + /// + public static string Type => "type"; + + /// + /// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. + /// + public static string Guid => "guid"; + } +} diff --git a/src/Kyoo.Authentication/Models/Options/PermissionOption.cs b/src/Kyoo.Authentication/Models/Options/PermissionOption.cs index 5adca2de..c7654465 100644 --- a/src/Kyoo.Authentication/Models/Options/PermissionOption.cs +++ b/src/Kyoo.Authentication/Models/Options/PermissionOption.cs @@ -16,6 +16,10 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . +using System; +using System.Linq; +using Kyoo.Abstractions.Models.Permissions; + namespace Kyoo.Authentication.Models { /// @@ -28,14 +32,28 @@ namespace Kyoo.Authentication.Models /// public const string Path = "authentication:permissions"; + /// + /// All permissions possibles, this is used to create an admin group. + /// + public static string[] Admin + { + get + { + return Enum.GetNames() + .SelectMany(group => Enum.GetNames() + .Select(kind => $"{group}.{kind}".ToLowerInvariant()) + ).ToArray(); + } + } + /// /// The default permissions that will be given to a non-connected user. /// - public string[] Default { get; set; } = new[] { "overall.read", "overall.write" }; + public string[] Default { get; set; } = new[] { "overall.read" }; /// /// Permissions applied to a new user. /// - public string[] NewUser { get; set; } = new[] { "overall.read", "overall.write" }; + public string[] NewUser { get; set; } = new[] { "overall.read" }; } } diff --git a/src/Kyoo.Authentication/Views/AuthApi.cs b/src/Kyoo.Authentication/Views/AuthApi.cs index 10dbdccb..b2f88e92 100644 --- a/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/src/Kyoo.Authentication/Views/AuthApi.cs @@ -25,9 +25,11 @@ using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Permissions; using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication.Models; using Kyoo.Authentication.Models.DTO; 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; @@ -52,15 +54,22 @@ namespace Kyoo.Authentication.Views /// private readonly ITokenController _token; + /// + /// The permisson options. + /// + private readonly IOptionsMonitor _permissions; + /// /// Create a new . /// /// The repository used to check if the user exists. /// The token generator. - public AuthApi(IUserRepository users, ITokenController token) + /// The permission opitons. + public AuthApi(IUserRepository users, ITokenController token, IOptionsMonitor permissions) { _users = users; _token = token; + _permissions = permissions; } /// @@ -115,6 +124,10 @@ namespace Kyoo.Authentication.Views public async Task> Register([FromBody] RegisterRequest request) { User user = request.ToUser(); + user.Permissions = _permissions.CurrentValue.NewUser; + // If no users exists, the new one will be an admin. Give it every permissions. + if (await _users.GetOrDefault(where: x => true) == null) + user.Permissions = PermissionOption.Admin; try { await _users.Create(user); @@ -174,22 +187,24 @@ namespace Kyoo.Authentication.Views /// logged in. /// /// The currently authenticated user. + /// The user is not authenticated. /// The given access token is invalid. [HttpGet("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> GetMe() { - if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID)) - return Forbid(); + if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) + return Unauthorized(new RequestError("User not authenticated")); try { return await _users.Get(userID); } catch (ItemNotFoundException) { - return Forbid(); + return Forbid(new RequestError("Invalid token")); } } @@ -201,15 +216,17 @@ namespace Kyoo.Authentication.Views /// /// The new data for the current user. /// The currently authenticated user after modifications. + /// The user is not authenticated. /// The given access token is invalid. [HttpPut("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> EditMe(User user) { - if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID)) - return Forbid(); + if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) + return Unauthorized(new RequestError("User not authenticated")); try { user.ID = userID; @@ -217,7 +234,7 @@ namespace Kyoo.Authentication.Views } catch (ItemNotFoundException) { - return Forbid(); + return Forbid(new RequestError("Invalid token")); } } @@ -229,15 +246,17 @@ namespace Kyoo.Authentication.Views /// /// The new data for the current user. /// The currently authenticated user after modifications. + /// The user is not authenticated. /// The given access token is invalid. - [HttpPut("me")] + [HttpPatch("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> PatchMe(User user) { - if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID)) - return Forbid(); + if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) + return Unauthorized(new RequestError("User not authenticated")); try { user.ID = userID; @@ -245,7 +264,7 @@ namespace Kyoo.Authentication.Views } catch (ItemNotFoundException) { - return Forbid(); + return Forbid(new RequestError("Invalid token")); } } @@ -256,15 +275,17 @@ namespace Kyoo.Authentication.Views /// Delete the current account. /// /// The currently authenticated user after modifications. + /// The user is not authenticated. /// The given access token is invalid. [HttpDelete("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> DeleteMe() { - if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID)) - return Forbid(); + if (!int.TryParse(User.FindFirstValue(Claims.Id), out int userID)) + return Unauthorized(new RequestError("User not authenticated")); try { await _users.Delete(userID); @@ -272,7 +293,7 @@ namespace Kyoo.Authentication.Views } catch (ItemNotFoundException) { - return Forbid(); + return Forbid(new RequestError("Invalid token")); } } } diff --git a/tests/robot/auth/auth.robot b/tests/robot/auth/auth.robot index 5070f51b..6b361ddd 100644 --- a/tests/robot/auth/auth.robot +++ b/tests/robot/auth/auth.robot @@ -35,7 +35,7 @@ Logout Me cant be accessed without an account Get /auth/me Output - Integer response status 403 + Integer response status 401 Bad Account [Documentation] Login fails if user does not exist