mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
jwt: Starting to rework authentication
This commit is contained in:
parent
3962c0eb7a
commit
1a534d2325
@ -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
|
||||
/// </summary>
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The environment information to check if the app runs in debug mode
|
||||
/// </summary>
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new authentication module instance and use the given configuration and environment.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration to use</param>
|
||||
/// <param name="environment">The environment information to check if the app runs in debug mode</param>
|
||||
public AuthenticationModule(IConfiguration configuration,
|
||||
IWebHostEnvironment environment)
|
||||
public AuthenticationModule(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -132,21 +122,8 @@ namespace Kyoo.Authentication
|
||||
}, SA.StaticFiles),
|
||||
SA.New<IApplicationBuilder>(app =>
|
||||
{
|
||||
app.UseCookiePolicy(new CookiePolicyOptions
|
||||
{
|
||||
MinimumSameSitePolicy = SameSiteMode.Strict
|
||||
});
|
||||
app.UseAuthentication();
|
||||
}, SA.Authentication),
|
||||
SA.New<IApplicationBuilder>(app =>
|
||||
{
|
||||
app.Use((ctx, next) =>
|
||||
{
|
||||
ctx.SetIdentityServerOrigin(_configuration.GetPublicUrl().ToString());
|
||||
return next();
|
||||
});
|
||||
app.UseIdentityServer();
|
||||
}, SA.Endpoint),
|
||||
SA.New<IApplicationBuilder>(app => app.UseAuthorization(), SA.Authorization)
|
||||
};
|
||||
}
|
||||
|
54
src/Kyoo.Authentication/Controllers/ITokenController.cs
Normal file
54
src/Kyoo.Authentication/Controllers/ITokenController.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using Kyoo.Abstractions.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Kyoo.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The service that controls jwt creation and validation.
|
||||
/// </summary>
|
||||
public interface ITokenController
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new access token for the given user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to create a token for.</param>
|
||||
/// <param name="expireIn">When this token will expire.</param>
|
||||
/// <returns>A new, valid access token.</returns>
|
||||
string CreateAccessToken([NotNull] User user, out TimeSpan expireIn);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new refresh token for the given user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to create a token for.</param>
|
||||
/// <returns>A new, valid refresh token.</returns>
|
||||
Task<string> CreateRefreshToken([NotNull] User user);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">The refresh token to validate.</param>
|
||||
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
|
||||
/// <returns>The id of the token's user.</returns>
|
||||
int GetRefreshTokenUserID(string refreshToken);
|
||||
}
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using IdentityModel;
|
||||
|
||||
namespace Kyoo.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Some functions to handle password management.
|
||||
/// </summary>
|
||||
public static class PasswordUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate an OneTimeAccessCode.
|
||||
/// </summary>
|
||||
/// <returns>A new otac.</returns>
|
||||
public static string GenerateOTAC()
|
||||
{
|
||||
return CryptoRandom.CreateUniqueId();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hash a password to store it has a verification only.
|
||||
/// </summary>
|
||||
/// <param name="password">The password to hash</param>
|
||||
/// <returns>The hashed password</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a password is the same as a valid hashed password.
|
||||
/// </summary>
|
||||
/// <param name="password">The password to check</param>
|
||||
/// <param name="validPassword">
|
||||
/// The valid hashed password. This password must be hashed via <see cref="HashPassword"/>.
|
||||
/// </param>
|
||||
/// <returns>True if the password is valid, false otherwise.</returns>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
149
src/Kyoo.Authentication/Controllers/TokenController.cs
Normal file
149
src/Kyoo.Authentication/Controllers/TokenController.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The service that controls jwt creation and validation.
|
||||
/// </summary>
|
||||
public class TokenController : ITokenController
|
||||
{
|
||||
/// <summary>
|
||||
/// The options that this controller will use.
|
||||
/// </summary>
|
||||
private readonly IOptions<AuthenticationOption> _options;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration used to retrieve the public URL of kyoo.
|
||||
/// </summary>
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="TokenController"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The options that this controller will use.</param>
|
||||
/// <param name="configuration">The configuration used to retrieve the public URL of kyoo.</param>
|
||||
public TokenController(IOptions<AuthenticationOption> options, IConfiguration configuration)
|
||||
{
|
||||
_options = options;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<Claim> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> 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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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.");
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.12" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
|
||||
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
58
src/Kyoo.Authentication/Models/JwtToken.cs
Normal file
58
src/Kyoo.Authentication/Models/JwtToken.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Kyoo.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// A container representing the response of a login or token refresh.
|
||||
/// </summary>
|
||||
public class JwtToken
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of this token (always a Bearer).
|
||||
/// </summary>
|
||||
[JsonProperty("token_token")]
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType => "Bearer";&é"bbbbR"
|
||||
|
||||
/// <summary>
|
||||
/// The access token used to authorize requests.
|
||||
/// </summary>
|
||||
[JsonProperty("access_token")]
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; }p
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token used to retrieve a new access/refresh token when the access token has expired.
|
||||
/// </summary>
|
||||
[JsonProperty("refresh_token")]
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date when the access token will expire. After this date, the refresh token should be used to retrieve.
|
||||
/// a new token.cs
|
||||
/// </summary>
|
||||
[JsonProperty("expire_in")]
|
||||
[JsonPropertyName("expire_in")]
|
||||
public TimeSpan ExpireIn { get; set; }
|
||||
}
|
@ -16,31 +16,30 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
namespace Kyoo.Authentication.Models
|
||||
namespace Kyoo.Authentication.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The main authentication options.
|
||||
/// </summary>
|
||||
public class AuthenticationOption
|
||||
{
|
||||
/// <summary>
|
||||
/// The main authentication options.
|
||||
/// The path to get this option from the root configuration.
|
||||
/// </summary>
|
||||
public class AuthenticationOption
|
||||
{
|
||||
/// <summary>
|
||||
/// The path to get this option from the root configuration.
|
||||
/// </summary>
|
||||
public const string Path = "authentication";
|
||||
public const string Path = "authentication";
|
||||
|
||||
/// <summary>
|
||||
/// The secret used to encrypt the jwt.
|
||||
/// </summary>
|
||||
public string Secret { get; set; }
|
||||
/// <summary>
|
||||
/// The secret used to encrypt the jwt.
|
||||
/// </summary>
|
||||
public string Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Options for permissions
|
||||
/// </summary>
|
||||
public PermissionOption Permissions { get; set; }
|
||||
/// <summary>
|
||||
/// Options for permissions
|
||||
/// </summary>
|
||||
public PermissionOption Permissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Root path of user's profile pictures.
|
||||
/// </summary>
|
||||
public string ProfilePicturePath { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Root path of user's profile pictures.
|
||||
/// </summary>
|
||||
public string ProfilePicturePath { get; set; }
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Sign in, Sign up or refresh tokens.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("auth")]
|
||||
public class AuthView : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// TODO document this well.
|
||||
[Route("api/accounts")]
|
||||
[Route("api/account", Order = AlternativeRoute)]
|
||||
[ApiController]
|
||||
[ApiDefinition("Account")]
|
||||
public class AccountApi : Controller, IProfileService
|
||||
private readonly IUserRepository _users;
|
||||
|
||||
/// <summary>
|
||||
/// The token generator.
|
||||
/// </summary>
|
||||
private readonly ITokenController _token;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="AuthView"/>.
|
||||
/// </summary>
|
||||
/// <param name="users">The repository used to check if the user exists.</param>
|
||||
/// <param name="token">The token generator.</param>
|
||||
public AuthView(IUserRepository users, ITokenController token)
|
||||
{
|
||||
/// <summary>
|
||||
/// The repository to handle users.
|
||||
/// </summary>
|
||||
private readonly IUserRepository _users;
|
||||
_users = users;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file manager to send profile pictures
|
||||
/// </summary>
|
||||
private readonly IFileSystem _files;
|
||||
|
||||
/// <summary>
|
||||
/// Options about authentication. Those options are monitored and reloads are supported.
|
||||
/// </summary>
|
||||
private readonly IOptions<AuthenticationOption> _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="AccountApi"/> handle to handle login/users requests.
|
||||
/// </summary>
|
||||
/// <param name="users">The user repository to create and manage users</param>
|
||||
/// <param name="files">A file manager to send profile pictures</param>
|
||||
/// <param name="options">Authentication options (this may be hot reloaded)</param>
|
||||
public AccountApi(IUserRepository users,
|
||||
IFileSystem files,
|
||||
IOptions<AuthenticationOption> options)
|
||||
/// <summary>
|
||||
/// Login.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Login as a user and retrieve an access and a refresh token.
|
||||
/// </remarks>
|
||||
/// <param name="request">The body of the request.</param>
|
||||
/// <returns>A new access and a refresh token.</returns>
|
||||
/// <response code="400">The user and password does not match.</response>
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<JwtToken>> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register a new user and return a OTAC to connect to it.
|
||||
/// </remarks>
|
||||
/// <param name="request">The DTO register request</param>
|
||||
/// <returns>A OTAC to connect to this new account</returns>
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
|
||||
public async Task<ActionResult<OtacResponse>> 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"]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return an authentication properties based on a stay login property
|
||||
/// </summary>
|
||||
/// <param name="stayLogged">Should the user stay logged</param>
|
||||
/// <returns>Authentication properties based on a stay login</returns>
|
||||
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." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Login
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Login the current session.
|
||||
/// </remarks>
|
||||
/// <param name="login">The DTO login request</param>
|
||||
/// <returns>TODO</returns>
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest login)
|
||||
/// <summary>
|
||||
/// Register.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register a new user and get a new access/refresh token for this new user.
|
||||
/// </remarks>
|
||||
/// <param name="request">The body of the request.</param>
|
||||
/// <returns>A new access and a refresh token.</returns>
|
||||
/// <response code="400">The request is invalid.</response>
|
||||
/// <response code="409">A user already exists with this username or email address.</response>
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<JwtToken>> 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." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Use a OTAC to login a user.
|
||||
/// </summary>
|
||||
/// <param name="otac">The OTAC request</param>
|
||||
/// <returns>TODO</returns>
|
||||
[HttpPost("otac-login")]
|
||||
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
|
||||
|
||||
return new JwtToken
|
||||
{
|
||||
// TODO once hstore (Dictionary<string, string> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh a token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="token">A valid refresh token.</param>
|
||||
/// <returns>A new access and refresh token.</returns>
|
||||
/// <response code="400">The given refresh token is invalid.</response>
|
||||
[HttpGet("refresh")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<JwtToken>> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sign out an user
|
||||
/// </summary>
|
||||
/// <returns>TODO</returns>
|
||||
[HttpGet("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout()
|
||||
catch (ElementNotFound)
|
||||
{
|
||||
await HttpContext.SignOutAsync();
|
||||
return Ok();
|
||||
return BadRequest(new { Message = "Invalid refresh token." });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[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)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task IsActiveAsync(IsActiveContext context)
|
||||
{
|
||||
User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId()));
|
||||
context.IsActive = user != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the user's profile picture.
|
||||
/// </summary>
|
||||
/// <param name="slug">The user slug</param>
|
||||
/// <returns>The profile picture of the user or 404 if not found</returns>
|
||||
[HttpGet("picture/{slug}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update profile information (email, username, profile picture...)
|
||||
/// </summary>
|
||||
/// <param name="data">The new information</param>
|
||||
/// <returns>The edited user</returns>
|
||||
[HttpPut]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<User>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get permissions for a non connected user.
|
||||
/// </summary>
|
||||
/// <returns>The list of permissions of a default user.</returns>
|
||||
[HttpGet("permissions")]
|
||||
public ActionResult<IEnumerable<string>> GetDefaultPermissions()
|
||||
{
|
||||
return _options.Value.Permissions.Default ?? Array.Empty<string>();
|
||||
return BadRequest(new { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("anilist")]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
public IActionResult AniListLogin([FromQuery] Uri redirectUrl, [FromServices] IOptions<AniListOptions> anilist)
|
||||
{
|
||||
Dictionary<string, string> 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<ActionResult<User>> 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<ActionResult<JwtToken>> 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<ActionResult<User>> GetMe()
|
||||
{
|
||||
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
|
||||
return BadRequest("Invalid access token");
|
||||
return await _users.GetById(userID);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user