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.Exceptions; using Kyoo.Authentication.Models; using Kyoo.Authentication.Models.DTO; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; 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; /// /// 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) { _users = users; _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) { 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 }); } /// /// 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.ToIdentityUser(), StayLogged(otac.StayLoggedIn)); return Ok(); } /// /// Sign out an user /// [HttpGet("logout")] [Authorize] public async Task Logout() { await HttpContext.SignOutAsync(); return Ok(); } /// public async Task GetProfileDataAsync(ProfileDataRequestContext context) { 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))); } /// 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(); } } }