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();
}
}
}