using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using IdentityServer4.Extensions; using IdentityServer4.Models; using IdentityServer4.Services; using Kyoo.Authentication.Models; using Kyoo.Authentication.Models.DTO; using Kyoo.Controllers; using Kyoo.Models; using Kyoo.Models.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace Kyoo.Authentication.Views { /// /// The class responsible for login, logout, permissions and claims of a user. /// [Route("api/account")] [Route("api/accounts")] [ApiController] public class AccountApi : Controller, IProfileService { /// /// The repository to handle users. /// private readonly IUserRepository _users; /// /// The identity server interaction service to login users. /// // private readonly IIdentityServerInteractionService _interaction; /// /// A file manager to send profile pictures /// private readonly IFileManager _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 /// The identity server interaction service to login users. /// A file manager to send profile pictures /// Authentication options (this may be hot reloaded) public AccountApi(IUserRepository users, // IIdentityServerInteractionService interaction, IFileManager files, IOptions options) { _users = users; // _interaction = interaction; _files = files; _options = options; } /// /// 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")] 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 { await _users.Create(user); } catch (DuplicatedItemException) { return Conflict(new {Errors = new {Duplicate = new[] {"A user with this name already exists"}}}); } return Ok(new {Otac = 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) }; } /// /// Login the user. /// /// The DTO login request [HttpPost("login")] public async Task Login([FromBody] LoginRequest login) { // AuthorizationRequest context = await _interaction.GetAuthorizationContextAsync(login.ReturnURL); User user = await _users.Get(x => x.Username == login.Username); if (user == null) return Unauthorized(); if (!PasswordUtils.CheckPassword(login.Password, user.Password)) return Unauthorized(); await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(login.StayLoggedIn)); return Ok(new { RedirectUrl = login.ReturnURL, IsOk = true }); } /// /// Use a OTAC to login a user. /// /// The OTAC request [HttpPost("otac-login")] public async Task OtacLogin([FromBody] OtacRequest otac) { // 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) return BadRequest(new { code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password." }); await HttpContext.SignInAsync(user.ID.ToString(), user.ToPrincipal(), StayLogged(otac.StayLoggedIn)); return Ok(); } /// /// Sign out an user /// [HttpGet("logout")] [Authorize] public async Task Logout() { await HttpContext.SignOutAsync(); return Ok(); } // TODO check with the extension method public async Task GetProfileDataAsync(ProfileDataRequestContext context) { User user = await _users.Get(int.Parse(context.Subject.GetSubjectId())); if (user == null) return; context.IssuedClaims.AddRange(user.GetClaims()); } public async Task IsActiveAsync(IsActiveContext context) { User user = await _users.Get(int.Parse(context.Subject.GetSubjectId())); context.IsActive = user != null; } [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); } [HttpPut] [Authorize] public async Task> Update([FromForm] AccountUpdateRequest data) { User user = await _users.Get(int.Parse(HttpContext.User.GetSubjectId())); 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 = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString()); await using Stream file = _files.NewFile(path); await data.Picture.CopyToAsync(file); } return await _users.Edit(user, false); } [HttpGet("permissions")] public ActionResult> GetDefaultPermissions() { return _options.Value.Permissions.Default; } } }