增加github oauth登录

This commit is contained in:
Diego
2025-06-15 00:17:25 +08:00
parent 29365c4ef9
commit d33d900592
19 changed files with 293 additions and 136 deletions

View File

@@ -1,40 +0,0 @@
using Microsoft.AspNetCore.Authentication.OAuth;
using System.Text.Json;
namespace ThingsGateway.Admin.Application;
/// <summary>OAuthOptions 配置类</summary>
public abstract class AdminOAuthOptions : OAuthOptions
{
/// <summary>默认构造函数</summary>
protected AdminOAuthOptions()
{
ConfigureClaims();
this.Events.OnRemoteFailure = context =>
{
var redirectUri = string.IsNullOrEmpty(HomePath) ? "/" : HomePath;
context.Response.Redirect(redirectUri);
context.HandleResponse();
return Task.CompletedTask;
};
}
/// <summary>配置 Claims 映射</summary>
protected virtual void ConfigureClaims()
{
}
public virtual string GetName(JsonElement element)
{
JsonElement.ObjectEnumerator target = element.EnumerateObject();
return target.TryGetValue("name");
}
/// <summary>获得/设置 登陆后首页</summary>
public string HomePath { get; set; } = "/";
}

View File

@@ -1,12 +0,0 @@
namespace ThingsGateway.Admin.Application;
public class GiteeOAuthUser
{
public string Id { get; set; }
public string Login { get; set; }
public string Name { get; set; }
public string Avatar_Url { get; set; }
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json;
namespace ThingsGateway.Admin.Application;
public static class OAuthUserExtensions
{
public static GiteeOAuthUser ToAuthUser(this JsonElement element)
{
GiteeOAuthUser authUser = new GiteeOAuthUser();
JsonElement.ObjectEnumerator target = element.EnumerateObject();
authUser.Id = target.TryGetValue("id");
authUser.Login = target.TryGetValue("login");
authUser.Name = target.TryGetValue("name");
authUser.Avatar_Url = target.TryGetValue("avatar_url");
return authUser;
}
public static string TryGetValue(this JsonElement.ObjectEnumerator target, string propertyName)
{
return target.FirstOrDefault<JsonProperty>((Func<JsonProperty, bool>)(t => t.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))).Value.ToString() ?? string.Empty;
}
}

View File

@@ -1,18 +1,14 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using ThingsGateway.Extension;
@@ -80,6 +76,7 @@ public class AdminOAuthHandler<TOptions>(
AuthenticationProperties properties,
OAuthTokenResponse tokens)
{
Backchannel.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokens.AccessToken);
properties.RedirectUri = Options.HomePath;
properties.IsPersistent = true;
var appConfig = await configService.GetAppConfigAsync().ConfigureAwait(false);
@@ -90,7 +87,7 @@ public class AdminOAuthHandler<TOptions>(
properties.ExpiresUtc = TimeProvider.System.GetUtcNow().AddSeconds(result);
expire = (int)(result / 60.0);
}
var user = await HandleUserInfoAsync(tokens).ConfigureAwait(false);
var user = await Options.HandleUserInfoAsync(Context, tokens).ConfigureAwait(false);
var loginEvent = await GetLogin(expire).ConfigureAwait(false);
await UpdateUser(loginEvent).ConfigureAwait(false);
@@ -148,43 +145,8 @@ public class AdminOAuthHandler<TOptions>(
}
/// <summary>处理用户信息方法</summary>
protected virtual async Task<JsonElement> HandleUserInfoAsync(OAuthTokenResponse tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, BuildUserInfoUrl(tokens));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await Backchannel.SendAsync(request, Context.RequestAborted).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return JsonDocument.Parse(content).RootElement;
}
throw new OAuthTokenException($"OAuth user info endpoint failure: {await Display(response).ConfigureAwait(false)}");
}
/// <summary>生成用户信息请求地址方法</summary>
protected virtual string BuildUserInfoUrl(OAuthTokenResponse tokens)
{
return QueryHelpers.AddQueryString(Options.UserInformationEndpoint, new Dictionary<string, string>
{
{ "access_token", tokens.AccessToken }
});
}
/// <summary>生成错误信息方法</summary>
protected static async Task<string> Display(HttpResponseMessage response)
{
var output = new StringBuilder();
output.Append($"Status: {response.StatusCode}; ");
output.Append($"Headers: {response.Headers}; ");
output.Append($"Body: {await response.Content.ReadAsStringAsync().ConfigureAwait(false)};");
return output.ToString();
}
private async Task<LoginEvent> GetLogin(int expire)
{

View File

@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ThingsGateway.Admin.Application;
/// <summary>OAuthOptions 配置类</summary>
public abstract class AdminOAuthOptions : OAuthOptions
{
/// <summary>默认构造函数</summary>
protected AdminOAuthOptions()
{
ConfigureClaims();
this.Events.OnRemoteFailure = context =>
{
var redirectUri = string.IsNullOrEmpty(HomePath) ? "/" : HomePath;
context.Response.Redirect(redirectUri);
context.HandleResponse();
return Task.CompletedTask;
};
Backchannel = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator // 若测试用
});
Backchannel.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("ThingsGateway", "1.0"));
}
/// <summary>配置 Claims 映射</summary>
protected virtual void ConfigureClaims()
{
}
public virtual string GetName(JsonElement element)
{
JsonElement.ObjectEnumerator target = element.EnumerateObject();
return target.TryGetValue("name");
}
/// <summary>获得/设置 登陆后首页</summary>
public string HomePath { get; set; } = "/";
/// <summary>处理用户信息方法</summary>
public virtual async Task<JsonElement> HandleUserInfoAsync(HttpContext context, OAuthTokenResponse tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, BuildUserInfoUrl(tokens));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await Backchannel.SendAsync(request, context.RequestAborted).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return JsonDocument.Parse(content).RootElement;
}
throw new OAuthTokenException($"OAuth user info endpoint failure: {await Display(response).ConfigureAwait(false)}");
}
/// <summary>生成用户信息请求地址方法</summary>
protected virtual string BuildUserInfoUrl(OAuthTokenResponse tokens)
{
return QueryHelpers.AddQueryString(UserInformationEndpoint, new Dictionary<string, string>
{
{ "access_token", tokens.AccessToken }
});
}
/// <summary>生成错误信息方法</summary>
protected async Task<string> Display(HttpResponseMessage response)
{
var output = new StringBuilder();
output.Append($"Status: {response.StatusCode}; ");
output.Append($"Headers: {response.Headers}; ");
output.Append($"Body: {await response.Content.ReadAsStringAsync().ConfigureAwait(false)};");
return output.ToString();
}
}

View File

@@ -3,16 +3,20 @@ using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.WebUtilities;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ThingsGateway.NewLife.Log;
namespace ThingsGateway.Admin.Application;
public class GiteeOAuthOptions : AdminOAuthOptions
{
INoticeService _noticeService;
IVerificatInfoService _verificatInfoService;
public GiteeOAuthOptions() : base()
{
_noticeService = App.GetService<INoticeService>();
_verificatInfoService = App.GetService<IVerificatInfoService>();
this.SignInScheme = ClaimConst.Scheme;
this.AuthorizationEndpoint = "https://gitee.com/oauth/authorize";
this.TokenEndpoint = "https://gitee.com/oauth/token";
@@ -29,11 +33,14 @@ public class GiteeOAuthOptions : AdminOAuthOptions
Events.OnRedirectToAuthorizationEndpoint = context =>
{
//context.RedirectUri = context.RedirectUri.Replace("http%3A%2F%2F", "https%3A%2F%2F"); // 强制替换
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
Events.OnRemoteFailure = context =>
{
XTrace.WriteException(context.Failure);
return Task.CompletedTask;
};
}
/// <summary>刷新 Token 方法</summary>
@@ -60,16 +67,7 @@ public class GiteeOAuthOptions : AdminOAuthOptions
return OAuthTokenResponse.Failed(new OAuthTokenException($"OAuth token endpoint failure: {await Display(response).ConfigureAwait(false)}"));
}
/// <summary>生成错误信息方法</summary>
protected static async Task<string> Display(HttpResponseMessage response)
{
var output = new StringBuilder();
output.Append($"Status: {response.StatusCode}; ");
output.Append($"Headers: {response.Headers}; ");
output.Append($"Body: {await response.Content.ReadAsStringAsync().ConfigureAwait(false)};");
return output.ToString();
}
public override string GetName(JsonElement element)
{
@@ -77,7 +75,7 @@ public class GiteeOAuthOptions : AdminOAuthOptions
return target.TryGetValue("name");
}
private static async Task HandlerGiteeStarredUrl(OAuthCreatingTicketContext context, string repoFullName = "ThingsGateway/ThingsGateway")
private async Task HandlerGiteeStarredUrl(OAuthCreatingTicketContext context, string repoFullName = "ThingsGateway/ThingsGateway")
{
if (string.IsNullOrWhiteSpace(context.AccessToken))
throw new InvalidOperationException("Access token is missing.");
@@ -89,7 +87,7 @@ public class GiteeOAuthOptions : AdminOAuthOptions
{ "access_token", context.AccessToken }
};
var request = new HttpRequestMessage(HttpMethod.Put, QueryHelpers.AddQueryString(uri, queryString))
var request = new HttpRequestMessage(HttpMethod.Get, QueryHelpers.AddQueryString(uri, queryString))
{
Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } }
};
@@ -99,7 +97,17 @@ public class GiteeOAuthOptions : AdminOAuthOptions
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
throw new Exception($"Failed to star repository: {response.StatusCode}, {content}");
var id = context.Identity.Claims.FirstOrDefault(a => a.Type == ClaimConst.VerificatId).Value;
var verificatInfoIds = _verificatInfoService.GetOne(id.ToLong());
_ = Task.Run(async () =>
{
await Task.Delay(5000).ConfigureAwait(false);
await _noticeService.NavigationMesage(verificatInfoIds.ClientIds, "https://gitee.com/ThingsGateway/ThingsGateway", "创作不易如有帮助请star仓库").ConfigureAwait(false);
});
//throw new Exception($"Failed to star repository: {response.StatusCode}, {content}");
}

View File

@@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using ThingsGateway.NewLife.Log;
namespace ThingsGateway.Admin.Application;
public class GitHubOAuthOptions : AdminOAuthOptions
{
INoticeService _noticeService;
IVerificatInfoService _verificatInfoService;
public GitHubOAuthOptions() : base()
{
_noticeService = App.GetService<INoticeService>();
_verificatInfoService = App.GetService<IVerificatInfoService>();
SignInScheme = ClaimConst.Scheme;
AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
TokenEndpoint = "https://github.com/login/oauth/access_token";
UserInformationEndpoint = "https://api.github.com/user";
HomePath = "/";
CallbackPath = "/signin-github";
Scope.Add("read:user");
Scope.Add("public_repo"); // 需要用于 Star 仓库
Events.OnCreatingTicket = async context =>
{
await HandleGitHubStarAsync(context).ConfigureAwait(false);
};
Events.OnRedirectToAuthorizationEndpoint = context =>
{
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
Events.OnRemoteFailure = context =>
{
XTrace.WriteException(context.Failure);
return Task.CompletedTask;
};
}
protected override void ConfigureClaims()
{
ClaimActions.MapJsonKey(ClaimConst.AvatarUrl, "avatar_url");
ClaimActions.MapJsonKey(ClaimConst.Account, "login");
base.ConfigureClaims();
}
public override string GetName(JsonElement element)
{
if (element.TryGetProperty("login", out var loginProp))
{
return loginProp.GetString() ?? string.Empty;
}
return string.Empty;
}
private async Task HandleGitHubStarAsync(OAuthCreatingTicketContext context, string repoFullName = "ThingsGateway/ThingsGateway")
{
if (string.IsNullOrWhiteSpace(context.AccessToken))
throw new InvalidOperationException("Access token is missing.");
var request = new HttpRequestMessage(HttpMethod.Put, $"https://api.github.com/user/starred/{repoFullName}")
{
Headers =
{
Accept = { new MediaTypeWithQualityHeaderValue("application/vnd.github+json") },
Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken),
},
Content = new StringContent(string.Empty) // GitHub Star 接口需要空内容
};
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("ThingsGateway", "1.0")); // GitHub API 要求 User-Agent
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var id = context.Identity.Claims.FirstOrDefault(a => a.Type == ClaimConst.VerificatId).Value;
var verificatInfoIds = _verificatInfoService.GetOne(id.ToLong());
_ = Task.Run(async () =>
{
await Task.Delay(5000).ConfigureAwait(false);
await _noticeService.NavigationMesage(verificatInfoIds.ClientIds, "https://github.com/ThingsGateway/ThingsGateway", "创作不易如有帮助请star仓库").ConfigureAwait(false);
});
}
}
/// <summary>处理用户信息方法</summary>
public override async Task<JsonElement> HandleUserInfoAsync(HttpContext context, OAuthTokenResponse tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, UserInformationEndpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("ThingsGateway", "1.0")); // GitHub API 要求 User-Agent
var response = await Backchannel.SendAsync(request, context.RequestAborted).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return JsonDocument.Parse(content).RootElement;
}
throw new OAuthTokenException($"OAuth user info endpoint failure: {await Display(response).ConfigureAwait(false)}");
}
}

View File

@@ -0,0 +1,6 @@
namespace ThingsGateway.Admin.Application;
public class GithubOAuthSettings : GiteeOAuthSettings
{
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json;
namespace ThingsGateway.Admin.Application;
public static class OAuthUserExtensions
{
public static string TryGetValue(this JsonElement.ObjectEnumerator target, string propertyName)
{
return target.FirstOrDefault<JsonProperty>((Func<JsonProperty, bool>)(t => t.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))).Value.ToString() ?? string.Empty;
}
}

View File

@@ -5,6 +5,9 @@
@<div>
<span class="mx-3">@item.ConfirmMessage</span>
<Button Text=@Localizers["Jump"] Color="Color.Link" OnClick="()=>NavigationManager.NavigateTo(item.Uri)"></Button>
<a href=@item.Uri target="_blank">
@item.Uri
</a>
</div>;
}

View File

@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<PluginVersion>10.7.57</PluginVersion>
<ProPluginVersion>10.7.57</ProPluginVersion>
<PluginVersion>10.7.58</PluginVersion>
<ProPluginVersion>10.7.58</ProPluginVersion>
<AuthenticationVersion>2.6.0</AuthenticationVersion>
<NET8Version>8.0.17</NET8Version>
<NET9Version>9.0.6</NET9Version>

View File

@@ -0,0 +1,10 @@
{
"GiteeOAuthSettings": {
"ClientId": "237adcc9fd39a71d53691d96642e1c2432db90ad93c02f72604897a348f347c6",
"ClientSecret": "4650577daf4cb02b325f3ae163e6706e87c4dc2ad615b035821b21116b279bca"
},
"GithubOAuthSettings": {
"ClientId": "Ov23lijDvpmRPawSDyWN",
"ClientSecret": "1f4aab562651dbe5d6a637afc57a5ca9a397ac07"
}
}

View File

@@ -48,7 +48,8 @@
<h5 class="mt-2 mb-12 ">@Localizer["Welcome"] 👋</h5>
@if(WebsiteOption.Value.Demo)
{
<Button Class="btn-block mt-5" IsAsync OnClick=GiteeLogin>@Localizer["GiteeLogin"]</Button>
<Button class="btn-block mt-5" IsAsync OnClick=GithubLogin>@Localizer["GithubLogin"]</Button>
<Button class="btn-block mt-5" IsAsync OnClick=GiteeLogin>@Localizer["GiteeLogin"]</Button>
}
else
{

View File

@@ -64,14 +64,25 @@ public partial class Login
_versionString = $"v{VersionService.Version}";
return base.OnInitializedAsync();
}
private void GiteeLogin()
{
var websiteOptions = App.GetOptions<WebsiteOptions>()!;
if (websiteOptions.Demo)
{
NavigationManager.NavigateTo("/api/auth/oauth-login", forceLoad: true);
NavigationManager.NavigateTo("/api/auth/oauth-login?scheme=Gitee", forceLoad: true);
}
}
private void GithubLogin()
{
var websiteOptions = App.GetOptions<WebsiteOptions>()!;
if (websiteOptions.Demo)
{
NavigationManager.NavigateTo("/api/auth/oauth-login?scheme=Github", forceLoad: true);
}
}
[Inject]
NavigationManager NavigationManager { get; set; }
private async Task LoginAsync(EditContext context)

View File

@@ -35,6 +35,7 @@
},
"ThingsGateway.Server.Login": {
"GiteeLogin": "Gitee OAuth",
"GithubLogin": "Github OAuth",
"LoginErrorc2": "Please contact the administrator!",
"LoginErrorh1": "Login Error",
"LoginErrorh2": "Login Failed",

View File

@@ -35,6 +35,7 @@
},
"ThingsGateway.Server.Login": {
"GiteeLogin": "Gitee授权登录",
"GithubLogin": "Github授权登录",
"LoginErrorc2": "请联系管理员!",
"LoginErrorh1": "登录异常",
"LoginErrorh2": "登录失败",

View File

@@ -296,6 +296,14 @@ public class Startup : AppStartup
options.ClientSecret = data.ClientSecret;
});
authenticationBuilder.AddOAuth<GitHubOAuthOptions, AdminOAuthHandler<GitHubOAuthOptions>>("Github", "Github", options =>
{
var data = App.GetConfig<GithubOAuthSettings>("GithubOAuthSettings");
options.ClientId = data.ClientId;
options.ClientSecret = data.ClientSecret;
});
}
// 添加jwt授权

View File

@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>10.7.57</Version>
<Version>10.7.58</Version>
</PropertyGroup>
<ItemGroup>