// 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 . 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; /// /// Sign in, Sign up or refresh tokens. /// [ApiController] [Route("auth")] [ApiDefinition("Authentication", Group = UsersGroup)] public class AuthApi( IUserRepository users, OidcController oidc, ITokenController tokenController, IThumbnailsManager thumbs, PermissionOption options ) : ControllerBase { /// /// Create a new Forbidden result from an object. /// /// The json value to output on the response. /// A new forbidden result with the given json object. public static ObjectResult Forbid(object value) { return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; } private static string _BuildUrl(string baseUrl, Dictionary queryParams) { char querySep = baseUrl.Contains('?') ? '&' : '?'; foreach ((string key, string? val) in queryParams) { if (val is null) continue; baseUrl += $"{querySep}{key}={val}"; querySep = '&'; } return baseUrl; } /// /// Oauth Login. /// /// /// Login via a registered oauth provider. /// /// The provider code. /// /// A url where you will be redirected with the query params provider, code and error. It can be a deep link. /// /// A redirect to the provider's login page. /// The provider is not register with this instance of kyoo. [HttpGet("login/{provider}")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] public ActionResult 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, } ) ); } /// /// Oauth Code Redirect. /// /// /// 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. /// /// A redirect to the provider's login page. /// The provider gave an error. [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, } ) ); } /// /// Oauth callback /// /// /// This route should be manually called by the page that got redirected to after a call to /login/{provider}. /// /// A jwt token /// Bad provider or code [HttpPost("callback/{provider}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] public async Task> 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 ); } /// /// Unlink account /// /// /// Unlink your account from an external account. /// /// The provider code. /// Your updated user account [HttpDelete("login/{provider}")] [ProducesResponseType(StatusCodes.Status200OK)] [UserOnly] public Task UnlinkAccount(string provider) { Guid id = User.GetIdOrThrow(); return users.DeleteExternalToken(id, provider); } /// /// Login. /// /// /// Login as a user and retrieve an access and a refresh token. /// /// The body of the request. /// A new access and a refresh token. /// The user and password does not match. [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] [DisableOnEnvVar("AUTHENTICATION_DISABLE_PASSWORD_LOGIN")] public async Task> Login([FromBody] LoginRequest request) { User? user = await users.GetOrDefault( new Filter.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 ); } /// /// Register. /// /// /// Register a new user and get a new access/refresh token for this new user. /// /// The body of the request. /// A new access and a refresh token. /// The request is invalid. /// A user already exists with this username or email address. [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> 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.")); } } /// /// Refresh a token. /// /// /// Refresh an access token using the given refresh token. A new access and refresh token are generated. /// /// A valid refresh token. /// A new access and refresh token. /// The given refresh token is invalid. [HttpGet("refresh")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> 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)); } } /// /// Reset your password /// /// /// Change your password. /// /// The old and new password /// Your account info. /// The old password is invalid. [HttpPost("password-reset")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> 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; } ); } /// /// Get authenticated user. /// /// /// Get information about the currently authenticated user. This can also be used to ensure that you are /// logged in. /// /// The currently authenticated user. /// The user is not authenticated. /// The given access token is invalid. [HttpGet("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> GetMe() { try { return await users.Get(User.GetIdOrThrow()); } catch (ItemNotFoundException) { return Forbid(new RequestError("Invalid token")); } } /// /// Edit self /// /// /// Edit information about the currently authenticated user. /// /// The new data for the current user. /// The currently authenticated user after modifications. /// The user is not authenticated. /// The given access token is invalid. [HttpPut("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> EditMe(User user) { try { user.Id = User.GetIdOrThrow(); return await users.Edit(user); } catch (ItemNotFoundException) { return Forbid(new RequestError("Invalid token")); } } /// /// Patch self /// /// /// Edit only provided informations about the currently authenticated user. /// /// The new data for the current user. /// The currently authenticated user after modifications. /// The user is not authenticated. /// The given access token is invalid. [HttpPatch("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> PatchMe([FromBody] Patch 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")); } } /// /// Delete account /// /// /// Delete the current account. /// /// The user is not authenticated. /// The given access token is invalid. [HttpDelete("me")] [UserOnly] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task> DeleteMe() { try { await users.Delete(User.GetIdOrThrow()); return NoContent(); } catch (ItemNotFoundException) { return Forbid(new RequestError("Invalid token")); } } /// /// Get profile picture /// /// /// Get your profile picture /// /// The user is not authenticated. /// The given access token is invalid. [HttpGet("me/logo")] [UserOnly] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task 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); } /// /// Set profile picture /// /// /// Set your profile picture /// /// The user is not authenticated. /// The given access token is invalid. [HttpPost("me/logo")] [UserOnly] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task SetProfilePicture(IFormFile picture) { if (picture == null || picture.Length == 0) return BadRequest(); await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); return NoContent(); } /// /// Delete profile picture /// /// /// Delete your profile picture /// /// The user is not authenticated. /// The given access token is invalid. [HttpDelete("me/logo")] [UserOnly] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] public async Task DeleteProfilePicture() { await thumbs.SetUserImage(User.GetIdOrThrow(), null); return NoContent(); } }