Parse user profile and get jwt

This commit is contained in:
Zoe Roux 2024-03-02 13:01:02 +01:00
parent 35a69edfa2
commit 98e9ba0fa7
4 changed files with 181 additions and 27 deletions

View File

@ -89,14 +89,14 @@ namespace Kyoo.Authentication
return acc;
if (val.Key.Split("_") is not ["OIDC", string provider, string key])
{
logger.LogError("Invalid oidc config value: {}", val.Key);
logger.LogError("Invalid oidc config value: {Key}", val.Key);
return acc;
}
provider = provider.ToLowerInvariant();
key = key.ToLowerInvariant();
if (!acc.ContainsKey(provider))
acc.Add(provider, new());
acc.Add(provider, new(provider));
switch (key)
{
case "clientid":
@ -111,11 +111,15 @@ namespace Kyoo.Authentication
case "authorization":
acc[provider].AuthorizationUrl = val.Value;
break;
case "token":
acc[provider].TokenUrl = val.Value;
break;
case "userinfo":
acc[provider].UserinfoUrl = val.Value;
case "profile":
acc[provider].ProfileUrl = val.Value;
break;
default:
logger.LogError("Invalid oidc config value: {}", key);
logger.LogError("Invalid oidc config value: {Key}", key);
return acc;
}
return acc;

View File

@ -0,0 +1,33 @@
// 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.Collections.Generic;
using System.Text.Json.Serialization;
namespace Kyoo.Authentication.Models.DTO;
public class JwtProfile
{
public string Sub { get; set; }
public string? Name { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
[JsonExtensionData]
public Dictionary<string, object> Extra { get; set; }
}

View File

@ -64,8 +64,31 @@ public class PermissionOption
public class OidcProvider
{
public string AuthorizationUrl { get; set; }
public string UserinfoUrl { get; set; }
public string TokenUrl { get; set; }
public string ProfileUrl { get; set; }
public string ClientId { get; set; }
public string Secret { get; set; }
public string? Scope { get; set; }
public bool Enabled =>
AuthorizationUrl != null
&& TokenUrl != null
&& ProfileUrl != null
&& ClientId != null
&& Secret != null;
public OidcProvider(string provider)
{
switch (provider)
{
case "google":
AuthorizationUrl = "https://accounts.google.com/o/oauth2/v2/auth";
TokenUrl = "https://oauth2.googleapis.com/token";
ProfileUrl = "https://openidconnect.googleapis.com/v1/userinfo";
Scope = "email profile";
break;
default:
break;
}
}
}

View File

@ -17,7 +17,12 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
@ -31,6 +36,9 @@ using Kyoo.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using static Kyoo.Abstractions.Models.Utils.Constants;
using BCryptNet = BCrypt.Net.BCrypt;
@ -46,6 +54,7 @@ namespace Kyoo.Authentication.Views
IRepository<User> users,
ITokenController tokenController,
IThumbnailsManager thumbs,
IHttpClientFactory clientFactory,
PermissionOption options
) : ControllerBase
{
@ -59,6 +68,19 @@ namespace Kyoo.Authentication.Views
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>
@ -67,12 +89,12 @@ namespace Kyoo.Authentication.Views
/// </remarks>
/// <returns>A redirect to the provider's login page.</returns>
/// <response code="404">The provider is not register with this instance of kyoo.</response>
[HttpPost("login/{provider}")]
[HttpGet("login/{provider}")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
public ActionResult<JwtToken> LoginVia(string provider)
{
if (!options.OIDC.ContainsKey(provider))
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
{
return NotFound(
new RequestError(
@ -81,13 +103,19 @@ namespace Kyoo.Authentication.Views
);
}
OidcProvider prov = options.OIDC[provider];
char querySep = prov.AuthorizationUrl.Contains('?') ? '&' : '?';
string url = $"{prov.AuthorizationUrl}{querySep}response_type=code";
url += $"&client_id={prov.ClientId}";
url += $"&redirect_uri={options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}";
if (prov.Scope is not null)
url += $"&scope={prov.Scope}";
return Redirect(url);
return Redirect(
_BuildUrl(
prov.AuthorizationUrl,
new()
{
["response_type"] = "code",
["client_id"] = prov.ClientId,
["redirect_uri"] =
$"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}",
["scope"] = prov.Scope,
}
)
);
}
/// <summary>
@ -99,22 +127,88 @@ namespace Kyoo.Authentication.Views
/// </remarks>
/// <returns>A redirect to the provider's login page.</returns>
/// <response code="403">The provider gave an error.</response>
[HttpPost("callback/{provider}")]
[HttpGet("callback/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, dynamic val)
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
{
throw new NotImplementedException();
// User? user = await users.GetOrDefault(
// new Filter<User>.Lambda(x => x.ExternalId[provider].Id == val.Id)
// );
// if (user == null)
// user = await users.Create(val);
// return new JwtToken(
// tokenController.CreateAccessToken(user, out TimeSpan expireIn),
// await tokenController.CreateRefreshToken(user),
// expireIn
// );
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."));
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(
_BuildUrl(
prov.TokenUrl,
new()
{
["code"] = code,
["client_id"] = prov.ClientId,
["client_secret"] = prov.Secret,
["redirect_uri"] =
$"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}",
["grant_type"] = "authorization_code",
}
),
null
);
if (!resp.IsSuccessStatusCode)
return BadRequest("Invalid code or configuration.");
JwtToken? token = await resp.Content.ReadFromJsonAsync<JwtToken>();
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<JwtProfile>(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;
if (profile.Username is not null)
newUser.Username = profile.Username;
else if (profile.Name is not null)
newUser.Username = profile.Name;
else
{
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.Slug = Utils.Utility.ToSlug(newUser.Username);
newUser.ExternalId.Add(provider, extToken);
User? user = await users.GetOrDefault(
new Filter<User>.Lambda(x => x.ExternalId[provider].Id == extToken.Id)
);
if (user == null)
user = await users.Create(newUser);
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
/// <summary>