mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-23 17:52:36 -04:00
Signed-off-by: Fred Heinecke <fred.heinecke@yahoo.com> Signed-off-by: solidDoWant <fred.heinecke@yahoo.com>
502 lines
16 KiB
C#
502 lines
16 KiB
C#
// 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.IO;
|
|
using System.Threading.Tasks;
|
|
using Kyoo.Abstractions.Controllers;
|
|
using Kyoo.Abstractions.Models;
|
|
using Kyoo.Abstractions.Models.Attributes;
|
|
using Kyoo.Abstractions.Models.Exceptions;
|
|
using Kyoo.Abstractions.Models.Permissions;
|
|
using Kyoo.Abstractions.Models.Utils;
|
|
using Kyoo.Authentication.Attributes;
|
|
using Kyoo.Authentication.Models;
|
|
using Kyoo.Authentication.Models.DTO;
|
|
using Kyoo.Models;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
|
using BCryptNet = BCrypt.Net.BCrypt;
|
|
|
|
namespace Kyoo.Authentication.Views;
|
|
|
|
/// <summary>
|
|
/// Sign in, Sign up or refresh tokens.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("auth")]
|
|
[ApiDefinition("Authentication", Group = UsersGroup)]
|
|
public class AuthApi(
|
|
IUserRepository users,
|
|
OidcController oidc,
|
|
ITokenController tokenController,
|
|
IThumbnailsManager thumbs,
|
|
PermissionOption options
|
|
) : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// Create a new Forbidden result from an object.
|
|
/// </summary>
|
|
/// <param name="value">The json value to output on the response.</param>
|
|
/// <returns>A new forbidden result with the given json object.</returns>
|
|
public static ObjectResult Forbid(object value)
|
|
{
|
|
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
|
|
}
|
|
|
|
private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams)
|
|
{
|
|
char querySep = baseUrl.Contains('?') ? '&' : '?';
|
|
foreach ((string key, string? val) in queryParams)
|
|
{
|
|
if (val is null)
|
|
continue;
|
|
baseUrl += $"{querySep}{key}={val}";
|
|
querySep = '&';
|
|
}
|
|
return baseUrl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Oauth Login.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Login via a registered oauth provider.
|
|
/// </remarks>
|
|
/// <param name="provider">The provider code.</param>
|
|
/// <param name="redirectUrl">
|
|
/// A url where you will be redirected with the query params provider, code and error. It can be a deep link.
|
|
/// </param>
|
|
/// <returns>A redirect to the provider's login page.</returns>
|
|
/// <response code="404">The provider is not register with this instance of kyoo.</response>
|
|
[HttpGet("login/{provider}")]
|
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
|
|
public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl)
|
|
{
|
|
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
|
{
|
|
return NotFound(
|
|
new RequestError(
|
|
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
|
)
|
|
);
|
|
}
|
|
OidcProvider prov = options.OIDC[provider];
|
|
return Redirect(
|
|
_BuildUrl(
|
|
prov.AuthorizationUrl,
|
|
new()
|
|
{
|
|
["response_type"] = "code",
|
|
["client_id"] = prov.ClientId,
|
|
["redirect_uri"] =
|
|
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
|
|
["scope"] = prov.Scope,
|
|
["state"] = redirectUrl,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Oauth Code Redirect.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This route is not meant to be called manually, the user should be redirected automatically here
|
|
/// after a successful login on the /login/{provider} page.
|
|
/// </remarks>
|
|
/// <returns>A redirect to the provider's login page.</returns>
|
|
/// <response code="403">The provider gave an error.</response>
|
|
[HttpGet("logged/{provider}")]
|
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
|
public ActionResult OauthCodeRedirect(string provider, string code, string state, string? error)
|
|
{
|
|
return Redirect(
|
|
_BuildUrl(
|
|
state,
|
|
new()
|
|
{
|
|
["provider"] = provider,
|
|
["code"] = code,
|
|
["error"] = error,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Oauth callback
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This route should be manually called by the page that got redirected to after a call to /login/{provider}.
|
|
/// </remarks>
|
|
/// <returns>A jwt token</returns>
|
|
/// <response code="400">Bad provider or code</response>
|
|
[HttpPost("callback/{provider}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
|
|
{
|
|
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
|
{
|
|
return NotFound(
|
|
new RequestError(
|
|
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
|
)
|
|
);
|
|
}
|
|
if (code == null)
|
|
return BadRequest(new RequestError("Invalid code."));
|
|
|
|
Guid? userId = User.GetId();
|
|
User user = userId.HasValue
|
|
? await oidc.LinkAccountOrLogin(userId.Value, provider, code)
|
|
: await oidc.LoginViaCode(provider, code);
|
|
return new JwtToken(
|
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
await tokenController.CreateRefreshToken(user),
|
|
expireIn
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unlink account
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Unlink your account from an external account.
|
|
/// </remarks>
|
|
/// <param name="provider">The provider code.</param>
|
|
/// <returns>Your updated user account</returns>
|
|
[HttpDelete("login/{provider}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[UserOnly]
|
|
public Task<User> UnlinkAccount(string provider)
|
|
{
|
|
Guid id = User.GetIdOrThrow();
|
|
return users.DeleteExternalToken(id, provider);
|
|
}
|
|
|
|
/// <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="403">The user and password does not match.</response>
|
|
[HttpPost("login")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
[DisableOnEnvVar("AUTHENTICATION_DISABLE_PASSWORD_LOGIN")]
|
|
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
|
|
{
|
|
User? user = await users.GetOrDefault(
|
|
new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
|
|
);
|
|
if (user != null && user.Password == null)
|
|
return Forbid(
|
|
new RequestError(
|
|
"This account was registerd via oidc. Please login via oidc or add a password to your account in the settings first"
|
|
)
|
|
);
|
|
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
|
|
return Forbid(new RequestError("The user and password does not match."));
|
|
|
|
return new JwtToken(
|
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
await tokenController.CreateRefreshToken(user),
|
|
expireIn
|
|
);
|
|
}
|
|
|
|
/// <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, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
|
|
[DisableOnEnvVar("AUTHENTICATION_DISABLE_USER_REGISTRATION")]
|
|
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
|
|
{
|
|
try
|
|
{
|
|
User user = await users.Create(request.ToUser());
|
|
return new JwtToken(
|
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
await tokenController.CreateRefreshToken(user),
|
|
expireIn
|
|
);
|
|
}
|
|
catch (DuplicatedItemException)
|
|
{
|
|
return Conflict(new RequestError("A user already exists with this username."));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refresh a token.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
|
|
/// </remarks>
|
|
/// <param name="token">A valid refresh token.</param>
|
|
/// <returns>A new access and refresh token.</returns>
|
|
/// <response code="403">The given refresh token is invalid.</response>
|
|
[HttpGet("refresh")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
|
|
{
|
|
try
|
|
{
|
|
Guid userId = tokenController.GetRefreshTokenUserID(token);
|
|
User user = await users.Get(userId);
|
|
return new JwtToken(
|
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
await tokenController.CreateRefreshToken(user),
|
|
expireIn
|
|
);
|
|
}
|
|
catch (ItemNotFoundException)
|
|
{
|
|
return Forbid(new RequestError("Invalid refresh token."));
|
|
}
|
|
catch (SecurityTokenException ex)
|
|
{
|
|
return Forbid(new RequestError(ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset your password
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Change your password.
|
|
/// </remarks>
|
|
/// <param name="request">The old and new password</param>
|
|
/// <returns>Your account info.</returns>
|
|
/// <response code="403">The old password is invalid.</response>
|
|
[HttpPost("password-reset")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request)
|
|
{
|
|
User user = await users.Get(User.GetIdOrThrow());
|
|
if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password))
|
|
return Forbid(new RequestError("The old password is invalid."));
|
|
return await users.Patch(
|
|
user.Id,
|
|
(user) =>
|
|
{
|
|
user.Password = BCryptNet.HashPassword(request.NewPassword);
|
|
return user;
|
|
}
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get authenticated user.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Get information about the currently authenticated user. This can also be used to ensure that you are
|
|
/// logged in.
|
|
/// </remarks>
|
|
/// <returns>The currently authenticated user.</returns>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpGet("me")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<User>> GetMe()
|
|
{
|
|
try
|
|
{
|
|
return await users.Get(User.GetIdOrThrow());
|
|
}
|
|
catch (ItemNotFoundException)
|
|
{
|
|
return Forbid(new RequestError("Invalid token"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Edit self
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Edit information about the currently authenticated user.
|
|
/// </remarks>
|
|
/// <param name="user">The new data for the current user.</param>
|
|
/// <returns>The currently authenticated user after modifications.</returns>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpPut("me")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<User>> EditMe(User user)
|
|
{
|
|
try
|
|
{
|
|
user.Id = User.GetIdOrThrow();
|
|
return await users.Edit(user);
|
|
}
|
|
catch (ItemNotFoundException)
|
|
{
|
|
return Forbid(new RequestError("Invalid token"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Patch self
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Edit only provided informations about the currently authenticated user.
|
|
/// </remarks>
|
|
/// <param name="patch">The new data for the current user.</param>
|
|
/// <returns>The currently authenticated user after modifications.</returns>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpPatch("me")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch)
|
|
{
|
|
Guid userId = User.GetIdOrThrow();
|
|
try
|
|
{
|
|
if (patch.Id.HasValue && patch.Id != userId)
|
|
throw new ArgumentException("Can't edit your user id.");
|
|
if (patch.ContainsKey(nameof(Abstractions.Models.User.Password)))
|
|
throw new ArgumentException(
|
|
"Can't edit your password via a PATCH. Use /auth/password-reset"
|
|
);
|
|
return await users.Patch(userId, patch.Apply);
|
|
}
|
|
catch (ItemNotFoundException)
|
|
{
|
|
return Forbid(new RequestError("Invalid token"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete account
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Delete the current account.
|
|
/// </remarks>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpDelete("me")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult<User>> DeleteMe()
|
|
{
|
|
try
|
|
{
|
|
await users.Delete(User.GetIdOrThrow());
|
|
return NoContent();
|
|
}
|
|
catch (ItemNotFoundException)
|
|
{
|
|
return Forbid(new RequestError("Invalid token"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get profile picture
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Get your profile picture
|
|
/// </remarks>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpGet("me/logo")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult> GetProfilePicture()
|
|
{
|
|
Stream img = await thumbs.GetUserImage(User.GetIdOrThrow());
|
|
// Allow clients to cache the image for 6 month.
|
|
Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}";
|
|
return File(img, "image/webp", true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set profile picture
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Set your profile picture
|
|
/// </remarks>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpPost("me/logo")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
|
|
{
|
|
if (picture == null || picture.Length == 0)
|
|
return BadRequest();
|
|
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete profile picture
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Delete your profile picture
|
|
/// </remarks>
|
|
/// <response code="401">The user is not authenticated.</response>
|
|
/// <response code="403">The given access token is invalid.</response>
|
|
[HttpDelete("me/logo")]
|
|
[UserOnly]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
public async Task<ActionResult> DeleteProfilePicture()
|
|
{
|
|
await thumbs.SetUserImage(User.GetIdOrThrow(), null);
|
|
return NoContent();
|
|
}
|
|
}
|