using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Security.Claims; using System.Threading.Tasks; using IdentityModel; using IdentityServer4.Models; using IdentityServer4.Services; using Kyoo.Authentication.Models.DTO; using Kyoo.Controllers; using Kyoo.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using AuthenticationOptions = Kyoo.Authentication.Models.AuthenticationOptions; namespace Kyoo.Authentication.Views { public class AccountData { [FromForm(Name = "email")] public string Email { get; set; } [FromForm(Name = "username")] public string Username { get; set; } [FromForm(Name = "picture")] public IFormFile Picture { get; set; } } /// /// 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; /// /// Options about authentication. Those options are monitored and reloads are supported. /// private readonly IOptionsMonitor _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. /// Authentication options (this may be hot reloaded) public AccountApi(IUserRepository users, IIdentityServerInteractionService interaction, IOptionsMonitor options) { _users = users; _interaction = interaction; _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.CurrentValue.Permissions.NewUser; user.Password = PasswordUtils.HashPassword(user.Password); user.ExtraData["otac"] = PasswordUtils.GenerateOTAC(); user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s"); await _users.Create(user); return 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 (context == null || 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) { User user = await _users.Get(x => x.ExtraData["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 _userManager.GetUserAsync(context.Subject); if (user != null) { List claims = new() { new Claim(JwtClaimTypes.Email, user.Email), new Claim(JwtClaimTypes.Name, user.Username), new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}") }; Claim perms = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(x => x.Type == "permissions"); if (perms != null) claims.Add(perms); context.IssuedClaims.AddRange(claims); } } public async Task IsActiveAsync(IsActiveContext context) { User user = await _userManager.GetUserAsync(context.Subject); context.IsActive = user != null; } [HttpGet("picture/{username}")] public async Task GetPicture(string username) { User user = await _userManager.FindByNameAsync(username); if (user == null) return BadRequest(); string path = Path.Combine(_picturePath, user.Id); if (!System.IO.File.Exists(path)) return NotFound(); return new PhysicalFileResult(path, "image/png"); } [HttpPost("update")] [Authorize] public async Task Update([FromForm] AccountData data) { User user = await _userManager.GetUserAsync(HttpContext.User); 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(_picturePath, user.Id); await using FileStream file = System.IO.File.Create(path); await data.Picture.CopyToAsync(file); } await _userManager.UpdateAsync(user); return Ok(); } [HttpGet("default-permissions")] public ActionResult> GetDefaultPermissions() { return _configuration.GetValue("defaultPermissions").Split(","); } } }