From 1a534d232560ca98820b2679f1211b29b704f48f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 17 Apr 2022 15:03:26 +0200 Subject: [PATCH] jwt: Starting to rework authentication --- .../AuthenticationModule.cs | 25 +- .../Controllers/ITokenController.cs | 54 +++ .../Controllers/PasswordUtils.cs | 77 ---- .../Controllers/TokenController.cs | 149 +++++++ .../Kyoo.Authentication.csproj | 1 + src/Kyoo.Authentication/Models/JwtToken.cs | 58 +++ .../Models/Options/AuthenticationOption.cs | 41 +- src/Kyoo.Authentication/Views/AccountApi.cs | 369 ++++++++---------- 8 files changed, 442 insertions(+), 332 deletions(-) create mode 100644 src/Kyoo.Authentication/Controllers/ITokenController.cs delete mode 100644 src/Kyoo.Authentication/Controllers/PasswordUtils.cs create mode 100644 src/Kyoo.Authentication/Controllers/TokenController.cs create mode 100644 src/Kyoo.Authentication/Models/JwtToken.cs diff --git a/src/Kyoo.Authentication/AuthenticationModule.cs b/src/Kyoo.Authentication/AuthenticationModule.cs index 604521ca..8b9d7e62 100644 --- a/src/Kyoo.Authentication/AuthenticationModule.cs +++ b/src/Kyoo.Authentication/AuthenticationModule.cs @@ -27,13 +27,11 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Authentication.Models; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.IdentityModel.Tokens; -using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode; namespace Kyoo.Authentication { @@ -63,21 +61,13 @@ namespace Kyoo.Authentication /// private readonly IConfiguration _configuration; - /// - /// The environment information to check if the app runs in debug mode - /// - private readonly IWebHostEnvironment _environment; - /// /// Create a new authentication module instance and use the given configuration and environment. /// /// The configuration to use - /// The environment information to check if the app runs in debug mode - public AuthenticationModule(IConfiguration configuration, - IWebHostEnvironment environment) + public AuthenticationModule(IConfiguration configuration) { _configuration = configuration; - _environment = environment; } /// @@ -132,21 +122,8 @@ namespace Kyoo.Authentication }, SA.StaticFiles), SA.New(app => { - app.UseCookiePolicy(new CookiePolicyOptions - { - MinimumSameSitePolicy = SameSiteMode.Strict - }); app.UseAuthentication(); }, SA.Authentication), - SA.New(app => - { - app.Use((ctx, next) => - { - ctx.SetIdentityServerOrigin(_configuration.GetPublicUrl().ToString()); - return next(); - }); - app.UseIdentityServer(); - }, SA.Endpoint), SA.New(app => app.UseAuthorization(), SA.Authorization) }; } diff --git a/src/Kyoo.Authentication/Controllers/ITokenController.cs b/src/Kyoo.Authentication/Controllers/ITokenController.cs new file mode 100644 index 00000000..1bb29c03 --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/ITokenController.cs @@ -0,0 +1,54 @@ +// 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 . + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Kyoo.Abstractions.Models; +using Microsoft.IdentityModel.Tokens; + +namespace Kyoo.Authentication; + +/// +/// The service that controls jwt creation and validation. +/// +public interface ITokenController +{ + /// + /// Create a new access token for the given user. + /// + /// The user to create a token for. + /// When this token will expire. + /// A new, valid access token. + string CreateAccessToken([NotNull] User user, out TimeSpan expireIn); + + /// + /// Create a new refresh token for the given user. + /// + /// The user to create a token for. + /// A new, valid refresh token. + Task CreateRefreshToken([NotNull] User user); + + /// + /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. + /// + /// The refresh token to validate. + /// The given refresh token is not valid. + /// The id of the token's user. + int GetRefreshTokenUserID(string refreshToken); +} diff --git a/src/Kyoo.Authentication/Controllers/PasswordUtils.cs b/src/Kyoo.Authentication/Controllers/PasswordUtils.cs deleted file mode 100644 index 200c5346..00000000 --- a/src/Kyoo.Authentication/Controllers/PasswordUtils.cs +++ /dev/null @@ -1,77 +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 . - -using System; -using System.Linq; -using System.Security.Cryptography; -using IdentityModel; - -namespace Kyoo.Authentication -{ - /// - /// Some functions to handle password management. - /// - public static class PasswordUtils - { - /// - /// Generate an OneTimeAccessCode. - /// - /// A new otac. - public static string GenerateOTAC() - { - return CryptoRandom.CreateUniqueId(); - } - - /// - /// Hash a password to store it has a verification only. - /// - /// The password to hash - /// The hashed password - public static string HashPassword(string password) - { - byte[] salt = new byte[16]; -#pragma warning disable SYSLIB0023 - new RNGCryptoServiceProvider().GetBytes(salt); -#pragma warning restore SYSLIB0023 - Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000); - byte[] hash = pbkdf2.GetBytes(20); - byte[] hashBytes = new byte[36]; - Array.Copy(salt, 0, hashBytes, 0, 16); - Array.Copy(hash, 0, hashBytes, 16, 20); - return Convert.ToBase64String(hashBytes); - } - - /// - /// Check if a password is the same as a valid hashed password. - /// - /// The password to check - /// - /// The valid hashed password. This password must be hashed via . - /// - /// True if the password is valid, false otherwise. - public static bool CheckPassword(string password, string validPassword) - { - byte[] validHash = Convert.FromBase64String(validPassword); - byte[] salt = new byte[16]; - Array.Copy(validHash, 0, salt, 0, 16); - Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000); - byte[] hash = pbkdf2.GetBytes(20); - return hash.SequenceEqual(validHash.Skip(16)); - } - } -} diff --git a/src/Kyoo.Authentication/Controllers/TokenController.cs b/src/Kyoo.Authentication/Controllers/TokenController.cs new file mode 100644 index 00000000..aa305b13 --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/TokenController.cs @@ -0,0 +1,149 @@ +// 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 . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Kyoo.Abstractions; +using Kyoo.Abstractions.Models; +using Kyoo.Authentication.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Kyoo.Authentication; + +/// +/// The service that controls jwt creation and validation. +/// +public class TokenController : ITokenController +{ + /// + /// The options that this controller will use. + /// + private readonly IOptions _options; + + /// + /// The configuration used to retrieve the public URL of kyoo. + /// + private readonly IConfiguration _configuration; + + /// + /// Create a new . + /// + /// The options that this controller will use. + /// The configuration used to retrieve the public URL of kyoo. + public TokenController(IOptions options, IConfiguration configuration) + { + _options = options; + _configuration = configuration; + } + + /// + public string CreateAccessToken(User user, out TimeSpan expireIn) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + + 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 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") + }; + 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); + } + + /// + public Task CreateRefreshToken(User user) + { + 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)); + } + + /// + public int GetRefreshTokenUserID(string refreshToken) + { + SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret)); + JwtSecurityTokenHandler tokenHandler = new(); + ClaimsPrincipal principal; + try + { + 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."); + } +} diff --git a/src/Kyoo.Authentication/Kyoo.Authentication.csproj b/src/Kyoo.Authentication/Kyoo.Authentication.csproj index fd863584..bf1ebbef 100644 --- a/src/Kyoo.Authentication/Kyoo.Authentication.csproj +++ b/src/Kyoo.Authentication/Kyoo.Authentication.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Kyoo.Authentication/Models/JwtToken.cs b/src/Kyoo.Authentication/Models/JwtToken.cs new file mode 100644 index 00000000..e0676203 --- /dev/null +++ b/src/Kyoo.Authentication/Models/JwtToken.cs @@ -0,0 +1,58 @@ +// 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 . + +using System; +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace Kyoo.Authentication; + +/// +/// A container representing the response of a login or token refresh. +/// +public class JwtToken +{ + /// + /// The type of this token (always a Bearer). + /// + [JsonProperty("token_token")] + [JsonPropertyName("token_type")] + public string TokenType => "Bearer";&é"bbbbR" + + /// + /// The access token used to authorize requests. + /// + [JsonProperty("access_token")] + [JsonPropertyName("access_token")] + public string AccessToken { get; set; }p + + /// + /// The refresh token used to retrieve a new access/refresh token when the access token has expired. + /// + [JsonProperty("refresh_token")] + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// The date when the access token will expire. After this date, the refresh token should be used to retrieve. + /// a new token.cs + /// + [JsonProperty("expire_in")] + [JsonPropertyName("expire_in")] + public TimeSpan ExpireIn { get; set; } +} diff --git a/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs b/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs index dc8209aa..92a51e43 100644 --- a/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs +++ b/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs @@ -16,31 +16,30 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Authentication.Models +namespace Kyoo.Authentication.Models; + +/// +/// The main authentication options. +/// +public class AuthenticationOption { /// - /// The main authentication options. + /// The path to get this option from the root configuration. /// - public class AuthenticationOption - { - /// - /// The path to get this option from the root configuration. - /// - public const string Path = "authentication"; + public const string Path = "authentication"; - /// - /// The secret used to encrypt the jwt. - /// - public string Secret { get; set; } + /// + /// The secret used to encrypt the jwt. + /// + public string Secret { get; set; } - /// - /// Options for permissions - /// - public PermissionOption Permissions { get; set; } + /// + /// Options for permissions + /// + public PermissionOption Permissions { get; set; } - /// - /// Root path of user's profile pictures. - /// - public string ProfilePicturePath { get; set; } - } + /// + /// Root path of user's profile pictures. + /// + public string ProfilePicturePath { get; set; } } diff --git a/src/Kyoo.Authentication/Views/AccountApi.cs b/src/Kyoo.Authentication/Views/AccountApi.cs index a2cbf2f2..2957c73f 100644 --- a/src/Kyoo.Authentication/Views/AccountApi.cs +++ b/src/Kyoo.Authentication/Views/AccountApi.cs @@ -18,246 +18,195 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using IdentityServer4.Extensions; -using IdentityServer4.Models; -using IdentityServer4.Services; 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; +using Kyoo.Authentication; using Kyoo.Authentication.Models.DTO; -using Microsoft.AspNetCore.Authentication; +using Kyoo.Utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using static Kyoo.Abstractions.Models.Utils.Constants; +using Microsoft.IdentityModel.Tokens; +using BCryptNet = BCrypt.Net.BCrypt; -namespace Kyoo.Authentication.Views +namespace Amadeus.Server.Views.Auth; + +/// +/// Sign in, Sign up or refresh tokens. +/// +[ApiController] +[Route("auth")] +public class AuthView : ControllerBase { /// - /// The endpoint responsible for login, logout, permissions and claims of a user. - /// Documentation of this endpoint is a work in progress. + /// The repository used to check if the user exists. /// - /// TODO document this well. - [Route("api/accounts")] - [Route("api/account", Order = AlternativeRoute)] - [ApiController] - [ApiDefinition("Account")] - public class AccountApi : Controller, IProfileService + private readonly IUserRepository _users; + + /// + /// The token generator. + /// + private readonly ITokenController _token; + + /// + /// Create a new . + /// + /// The repository used to check if the user exists. + /// The token generator. + public AuthView(IUserRepository users, ITokenController token) { - /// - /// The repository to handle users. - /// - private readonly IUserRepository _users; + _users = users; + _token = token; + } - /// - /// A file manager to send profile pictures - /// - private readonly IFileSystem _files; - - /// - /// Options about authentication. Those options are monitored and reloads are supported. - /// - private readonly IOptions _options; - - /// - /// Create a new handle to handle login/users requests. - /// - /// The user repository to create and manage users - /// A file manager to send profile pictures - /// Authentication options (this may be hot reloaded) - public AccountApi(IUserRepository users, - IFileSystem files, - IOptions options) + /// + /// Login. + /// + /// + /// Login as a user and retrieve an access and a refresh token. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The user and password does not match. + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Login([FromBody] LoginRequest request) + { + User user = (await _users.GetAll()).FirstOrDefault(x => x.Username == request.Username); + if (user != null && BCryptNet.Verify(request.Password, user.Password)) { - _users = users; - _files = files; - _options = options; - } - - /// - /// Register - /// - /// - /// Register a new user and return a OTAC to connect to it. - /// - /// The DTO register request - /// A OTAC to connect to this new account - [HttpPost("register")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] - public async Task> Register([FromBody] RegisterRequest request) - { - User user = request.ToUser(); - user.Permissions = _options.Value.Permissions.NewUser; - user.Password = PasswordUtils.HashPassword(user.Password); - user.ExtraData["otac"] = PasswordUtils.GenerateOTAC(); - user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s"); - try + return new JwtToken { - await _users.Create(user); - } - catch (DuplicatedItemException) - { - return Conflict(new RequestError("A user with this name already exists")); - } - - return Ok(new OtacResponse(user.ExtraData["otac"])); - } - - /// - /// Return an authentication properties based on a stay login property - /// - /// Should the user stay logged - /// Authentication properties based on a stay login - private static AuthenticationProperties _StayLogged(bool stayLogged) - { - if (!stayLogged) - return null; - return new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(1) + 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." }); + } - /// - /// Login - /// - /// - /// Login the current session. - /// - /// The DTO login request - /// TODO - [HttpPost("login")] - public async Task Login([FromBody] LoginRequest login) + /// + /// Register. + /// + /// + /// Register a new user and get a new access/refresh token for this new user. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The request is invalid. + /// A user already exists with this username or email address. + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Register([FromBody] RegisterRequest request) + { + User user = request.ToUser(); + user.Password = BCryptNet.HashPassword(request.Password); + try { - User user = await _users.GetOrDefault(x => x.Username == login.Username); - - if (user == null) - return Unauthorized(); - if (!PasswordUtils.CheckPassword(login.Password, user.Password)) - return Unauthorized(); - - await HttpContext.SignInAsync(user.ToIdentityUser(), _StayLogged(login.StayLoggedIn)); - return Ok(new { RedirectUrl = login.ReturnURL, IsOk = true }); + await _users.Create(user); + } + catch (DuplicateField) + { + return Conflict(new { Message = "A user already exists with this username." }); } - /// - /// Use a OTAC to login a user. - /// - /// The OTAC request - /// TODO - [HttpPost("otac-login")] - public async Task OtacLogin([FromBody] OtacRequest otac) + + return new JwtToken { - // TODO once hstore (Dictionary accessor) are supported, use them. - // We retrieve all users, this is inefficient. - User user = (await _users.GetAll()).FirstOrDefault(x => x.ExtraData.GetValueOrDefault("otac") == otac.Otac); - if (user == null) - return Unauthorized(); - if (DateTime.ParseExact(user.ExtraData["otac-expire"], "s", CultureInfo.InvariantCulture) <= - DateTime.UtcNow) + AccessToken = _token.CreateAccessToken(user, out TimeSpan expireDate), + RefreshToken = await _token.CreateRefreshToken(user), + ExpireIn = expireDate + }; + } + + /// + /// Refresh a token. + /// + /// + /// 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. + /// + /// A valid refresh token. + /// A new access and refresh token. + /// The given refresh token is invalid. + [HttpGet("refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Refresh([FromQuery] string token) + { + try + { + int userId = _token.GetRefreshTokenUserID(token); + User user = await _users.GetById(userId); + return new JwtToken { - return BadRequest(new - { - code = "ExpiredOTAC", - description = "The OTAC has expired. Try to login with your password." - }); - } - - await HttpContext.SignInAsync(user.ToIdentityUser(), _StayLogged(otac.StayLoggedIn)); - return Ok(); + AccessToken = _token.CreateAccessToken(user, out TimeSpan expireDate), + RefreshToken = await _token.CreateRefreshToken(user), + ExpireIn = expireDate + }; } - - /// - /// Sign out an user - /// - /// TODO - [HttpGet("logout")] - [Authorize] - public async Task Logout() + catch (ElementNotFound) { - await HttpContext.SignOutAsync(); - return Ok(); + return BadRequest(new { Message = "Invalid refresh token." }); } - - /// - [ApiExplorerSettings(IgnoreApi = true)] - public async Task GetProfileDataAsync(ProfileDataRequestContext context) + catch (SecurityTokenException ex) { - User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId())); - if (user == null) - return; - context.IssuedClaims.AddRange(user.GetClaims()); - context.IssuedClaims.Add(new Claim("permissions", string.Join(',', user.Permissions))); - } - - /// - [ApiExplorerSettings(IgnoreApi = true)] - public async Task IsActiveAsync(IsActiveContext context) - { - User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId())); - context.IsActive = user != null; - } - - /// - /// Get the user's profile picture. - /// - /// The user slug - /// The profile picture of the user or 404 if not found - [HttpGet("picture/{slug}")] - public async Task GetPicture(string slug) - { - User user = await _users.GetOrDefault(slug); - if (user == null) - return NotFound(); - string path = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString()); - return _files.FileResult(path); - } - - /// - /// Update profile information (email, username, profile picture...) - /// - /// The new information - /// The edited user - [HttpPut] - [Authorize] - public async Task> Update([FromForm] AccountUpdateRequest data) - { - User user = await _users.GetOrDefault(int.Parse(HttpContext.User.GetSubjectId())); - - if (user == null) - return Unauthorized(); - 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 = _files.Combine(_options.Value.ProfilePicturePath, user.ID.ToString()); - await using Stream file = await _files.NewFile(path); - await data.Picture.CopyToAsync(file); - } - return await _users.Edit(user, false); - } - - /// - /// Get permissions for a non connected user. - /// - /// The list of permissions of a default user. - [HttpGet("permissions")] - public ActionResult> GetDefaultPermissions() - { - return _options.Value.Permissions.Default ?? Array.Empty(); + return BadRequest(new { ex.Message }); } } + + [HttpGet("anilist")] + [ProducesResponseType(StatusCodes.Status302Found)] + public IActionResult AniListLogin([FromQuery] Uri redirectUrl, [FromServices] IOptions anilist) + { + Dictionary 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> 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> 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> GetMe() + { + if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID)) + return BadRequest("Invalid access token"); + return await _users.GetById(userID); + } }