diff --git a/back/src/Kyoo.Abstractions/Models/Resources/User.cs b/back/src/Kyoo.Abstractions/Models/Resources/User.cs index 2f049b7f..6afeccb8 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/User.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/User.cs @@ -100,14 +100,19 @@ namespace Kyoo.Abstractions.Models public string Id { get; set; } /// - /// A jwt token used to interact with the service. - /// Do not forget to refresh it when using it if necessary. + /// The username on the external service. /// - public JwtToken Token { get; set; } + public string Username { get; set; } /// /// The link to the user profile on this website. Null if it does not exist. /// public string? ProfileUrl { get; set; } + + /// + /// A jwt token used to interact with the service. + /// Do not forget to refresh it when using it if necessary. + /// + public JwtToken Token { get; set; } } } diff --git a/back/src/Kyoo.Authentication/Controllers/OidcController.cs b/back/src/Kyoo.Authentication/Controllers/OidcController.cs new file mode 100644 index 00000000..0a1a4f72 --- /dev/null +++ b/back/src/Kyoo.Authentication/Controllers/OidcController.cs @@ -0,0 +1,113 @@ +// 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.ComponentModel.DataAnnotations; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Authentication.Models; +using Kyoo.Authentication.Models.DTO; +using Kyoo.Core.Controllers; + +namespace Kyoo.Authentication; + +public class OidcController( + UserRepository users, + IHttpClientFactory clientFactory, PermissionOption options) +{ + private async Task<(User, ExternalToken)> _TranslateCode(string provider, string code) + { + OidcProvider prov = options.OIDC[provider]; + + HttpClient client = clientFactory.CreateClient(); + + string auth = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") + ); + client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); + + HttpResponseMessage resp = await client.PostAsync( + prov.TokenUrl, + new FormUrlEncodedContent( + new Dictionary() + { + ["code"] = code, + ["client_id"] = prov.ClientId, + ["client_secret"] = prov.Secret, + ["redirect_uri"] = + $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["grant_type"] = "authorization_code", + } + ) + ); + if (!resp.IsSuccessStatusCode) + throw new ValidationException( + $"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}" + ); + JwtToken? token = await resp.Content.ReadFromJsonAsync(); + if (token is null) + throw new ValidationException("Could not retrive token."); + + client.DefaultRequestHeaders.Remove("Authorization"); + client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); + JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl); + if (profile is null || profile.Sub is null) + throw new ValidationException("Missing sub on user object"); + ExternalToken extToken = new() { Id = profile.Sub, Token = token, }; + User newUser = new(); + if (profile.Email is not null) + newUser.Email = profile.Email; + string? username = profile.Username ?? profile.Name; + if (username is null) + { + throw new ValidationException( + $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}" + ); + } + extToken.Username = username; + newUser.Username = username; + newUser.Slug = Utils.Utility.ToSlug(newUser.Username); + newUser.ExternalId.Add(provider, extToken); + newUser.Permissions = options.NewUser; + return (newUser, extToken); + } + + public async Task LoginViaCode(string provider, string code) + { + (User newUser, ExternalToken extToken) = await _TranslateCode(provider, code); + User? user = await users.GetByExternalId(provider, extToken.Id); + if (user == null) + { + try + { + user = await users.Create(newUser); + } + catch + { + throw new ValidationException( + "A user already exists with the same username. If this is you, login via username and then link your account." + ); + } + } + return user; + } +} diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index e926cc4e..58ae93a7 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -49,9 +49,9 @@ namespace Kyoo.Authentication.Views [ApiDefinition("Authentication", Group = UsersGroup)] public class AuthApi( UserRepository users, + OidcController oidc, ITokenController tokenController, IThumbnailsManager thumbs, - IHttpClientFactory clientFactory, PermissionOption options ) : ControllerBase { @@ -174,77 +174,8 @@ namespace Kyoo.Authentication.Views } if (code == null) return BadRequest(new RequestError("Invalid code.")); - OidcProvider prov = options.OIDC[provider]; - HttpClient client = clientFactory.CreateClient(); - - string auth = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") - ); - client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); - - HttpResponseMessage resp = await client.PostAsync( - prov.TokenUrl, - new FormUrlEncodedContent( - new Dictionary() - { - ["code"] = code, - ["client_id"] = prov.ClientId, - ["client_secret"] = prov.Secret, - ["redirect_uri"] = - $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", - ["grant_type"] = "authorization_code", - } - ) - ); - if (!resp.IsSuccessStatusCode) - return BadRequest( - $"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}" - ); - JwtToken? token = await resp.Content.ReadFromJsonAsync(); - if (token is null) - return BadRequest("Could not retrive token."); - - client.DefaultRequestHeaders.Remove("Authorization"); - client.DefaultRequestHeaders.Add( - "Authorization", - $"{token.TokenType} {token.AccessToken}" - ); - JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl); - if (profile is null || profile.Sub is null) - return BadRequest("Missing sub on user object"); - ExternalToken extToken = new() { Id = profile.Sub, Token = token, }; - User newUser = new(); - if (profile.Email is not null) - newUser.Email = profile.Email; - string? username = profile.Username ?? profile.Name; - if (username is null) - { - return BadRequest( - new RequestError( - $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}" - ) - ); - } - newUser.Username = username; - newUser.Slug = Utils.Utility.ToSlug(newUser.Username); - newUser.ExternalId.Add(provider, extToken); - newUser.Permissions = options.NewUser; - - User? user = await users.GetByExternalId(provider, extToken.Id); - if (user == null) - { - try - { - user = await users.Create(newUser); - } - catch - { - return BadRequest( - "A user already exists with the same username. If this is you, login via username and then link your account." - ); - } - } + User user = await oidc.LoginViaCode(provider, code); return new JwtToken( tokenController.CreateAccessToken(user, out TimeSpan expireIn), await tokenController.CreateRefreshToken(user),