diff --git a/WotBlitzStatisticsPro.Common/Model/GitHubUser.cs b/WotBlitzStatisticsPro.Common/Model/GitHubUser.cs new file mode 100644 index 0000000..b5ec940 --- /dev/null +++ b/WotBlitzStatisticsPro.Common/Model/GitHubUser.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json.Serialization; + +namespace WotBlitzStatisticsPro.Common.Model +{ + /// + /// Represents authenticated GitHub user + /// + public class GitHubUser + { + /// + /// User ID + /// + [JsonPropertyName("id")] + public long Id { get; set; } + + /// + /// Login + /// + [JsonPropertyName("login")] + public string? Login { get; set; } + + /// + /// Avatar URL + /// + [JsonPropertyName("avatar_url")] + public string? Avatar { get; set; } + + /// + /// GitHub profile + /// + [JsonPropertyName("html_url")] + public string? ProfileLink { get; set; } + + /// + /// HitHub account creation time + /// + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + /// + /// Followers count + /// + [JsonPropertyName("followers")] + public int FollowersCount { get; set; } + } +} \ No newline at end of file diff --git a/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthAppConfiguration.cs b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthAppConfiguration.cs new file mode 100644 index 0000000..fc5899c --- /dev/null +++ b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthAppConfiguration.cs @@ -0,0 +1,20 @@ +namespace WotBlitzStatisticsPro.GraphQl.GitHubOauth +{ + public class GitHubOauthAppConfiguration + { + /// + /// Github OAuth clientId + /// + public string? ClientId { get; set; } + + /// + /// GutHub OAuth client secret + /// + public string? ClientSecret { get; set; } + + /// + /// GutHub user, that allowed to make dictionary updates mutation + /// + public string AllowedUser { get; set; } = "DrMboga"; + } +} \ No newline at end of file diff --git a/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthService.cs b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthService.cs new file mode 100644 index 0000000..b1bfdb3 --- /dev/null +++ b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/GitHubOauthService.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using WotBlitzStatisticsPro.Common.Model; + +namespace WotBlitzStatisticsPro.GraphQl.GitHubOauth +{ + public class GitHubOauthService: IGitHubOauthService + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly GitHubOauthAppConfiguration _configuration; + + public GitHubOauthService( + IHttpClientFactory httpClientFactory, + GitHubOauthAppConfiguration configuration) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + if (_configuration.ClientId == null || _configuration.ClientSecret == null) + { + throw new ArgumentNullException(nameof(_configuration)); + } + } + + public async Task GetGitHubUser(string oAuthCode) + { + var token = await GetAccessToken(oAuthCode); + return await GetUser(token); + } + + public string GenerateToken(GitHubUser user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.ClientSecret!)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + // generate jwt token + var claims = new[] + { + new Claim(ClaimTypes.Name, user.Login?? "unknown"), + }; + + var token = new JwtSecurityToken( + issuer: "https://WotBlitzStatisticsPro.com", + audience: "https://WotBlitzStatisticsPro.com", + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private async Task GetAccessToken(string oAuthCode) + { + string url = "https://github.com/login/oauth/access_token"; + + var parameters = new Dictionary + { + { "client_id", _configuration.ClientId! }, + { "client_secret", _configuration.ClientSecret! }, + { "code", oAuthCode } + }; + var encodedContent = new FormUrlEncodedContent(parameters!); + + var client = _httpClientFactory.CreateClient("github"); + + var response = await client.PostAsync(url, encodedContent).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var stringResponse = await response.Content.ReadAsStringAsync(); + + var regex = new Regex("access_token=(.{40})&scope=&token_type=bearer"); + var match = regex.Match(stringResponse); + + if (match.Success) + { + return match.Groups[1].Value; + } + + throw new ApplicationException($"Can not get access_token from string '{stringResponse}'"); + } + + private async Task GetUser(string token) + { + string url = "https://api.github.com/user"; + + var client = _httpClientFactory.CreateClient("github"); + client.DefaultRequestHeaders.Authorization + = new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Add("User-Agent", "WotBlitzStatisticsPro"); + + var response = await client.GetAsync(url); + + response.EnsureSuccessStatusCode(); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(responseStream); + } + } +} \ No newline at end of file diff --git a/WotBlitzStatisticsPro.GraphQl/GitHubOauth/IGitHubOauthService.cs b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/IGitHubOauthService.cs new file mode 100644 index 0000000..4743fa7 --- /dev/null +++ b/WotBlitzStatisticsPro.GraphQl/GitHubOauth/IGitHubOauthService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using WotBlitzStatisticsPro.Common.Model; + +namespace WotBlitzStatisticsPro.GraphQl.GitHubOauth +{ + public interface IGitHubOauthService + { + Task GetGitHubUser(string oAuthCode); + + string GenerateToken(GitHubUser user); + } +} \ No newline at end of file diff --git a/WotBlitzStatisticsPro.GraphQl/Mutation/AuthenticationMutation.cs b/WotBlitzStatisticsPro.GraphQl/Mutation/AuthenticationMutation.cs new file mode 100644 index 0000000..3e9b63e --- /dev/null +++ b/WotBlitzStatisticsPro.GraphQl/Mutation/AuthenticationMutation.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using HotChocolate.Types; +using Microsoft.Extensions.Logging; +using WotBlitzStatisticsPro.GraphQl.GitHubOauth; + + +namespace WotBlitzStatisticsPro.GraphQl.Mutation +{ + [ExtendObjectType(Name = "Mutation")] + public class AuthenticationMutation + { + private readonly IGitHubOauthService _guGitHubOauthService; + private readonly ILogger _logger; + + public AuthenticationMutation( + IGitHubOauthService guGitHubOauthService, + ILogger logger) + { + _guGitHubOauthService = guGitHubOauthService; + _logger = logger; + } + + /// + /// This method accepts GitHub oauth code, and returns JWT token with GutHub user as claim + /// + /// OAuth code can be generated by this URL https://github.com/login/oauth/authorize?client_id={yourClientId} + public async Task GitHubAuthenticate(string oauthCode) + { + try + { + var user = await _guGitHubOauthService.GetGitHubUser(oauthCode); + if (user == null) + { + throw new ApplicationException($"Can not find GitHub user by code {oauthCode}"); + } + return _guGitHubOauthService.GenerateToken(user); + } + catch (Exception e) + { + _logger.LogError(e,"Authentication fails"); + throw; + } + } + + } +} \ No newline at end of file diff --git a/WotBlitzStatisticsPro.GraphQl/Mutation/WotBlitzStatisticsMutation.cs b/WotBlitzStatisticsPro.GraphQl/Mutation/WotBlitzStatisticsMutation.cs index eb49b3a..8d0da36 100644 --- a/WotBlitzStatisticsPro.GraphQl/Mutation/WotBlitzStatisticsMutation.cs +++ b/WotBlitzStatisticsPro.GraphQl/Mutation/WotBlitzStatisticsMutation.cs @@ -1,4 +1,6 @@ using System.Threading.Tasks; +using HotChocolate.AspNetCore.Authorization; +using HotChocolate.Types; using WotBlitzStatisticsPro.Common.Model; using WotBlitzStatisticsPro.Logic; @@ -7,6 +9,7 @@ namespace WotBlitzStatisticsPro.GraphQl.Mutation /// /// WotBlitzStatistics Mutations /// + [ExtendObjectType(Name = "Mutation")] public class WotBlitzStatisticsMutation { private readonly IWargamingDictionaries _wargamingDictionaries; @@ -24,6 +27,7 @@ namespace WotBlitzStatisticsPro.GraphQl.Mutation /// /// Determines what dictionaries should be updated. /// Information about what was updated. + [Authorize(Policy = "GithubMbogaOnly")] public Task UpdateDictionaries( UpdateDictionariesRequest updateDictionariesRequest) { diff --git a/WotBlitzStatisticsPro.GraphQl/Startup.cs b/WotBlitzStatisticsPro.GraphQl/Startup.cs index 548748d..395e8b7 100644 --- a/WotBlitzStatisticsPro.GraphQl/Startup.cs +++ b/WotBlitzStatisticsPro.GraphQl/Startup.cs @@ -1,11 +1,18 @@ +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.IdentityModel.Tokens; using WotBlitzStatisticsPro.Common; using WotBlitzStatisticsPro.Common.Model; +using WotBlitzStatisticsPro.GraphQl.GitHubOauth; using WotBlitzStatisticsPro.GraphQl.Mutation; using WotBlitzStatisticsPro.GraphQl.ObjectTypes; using WotBlitzStatisticsPro.GraphQl.Query; @@ -32,33 +39,73 @@ namespace WotBlitzStatisticsPro.GraphQl var mongoConfig = new MongoSettings(); Configuration.GetSection("Mongo").Bind(mongoConfig); + var gitHubOauthConfig = new GitHubOauthAppConfiguration(); + Configuration.GetSection("GitHubOAuth").Bind(gitHubOauthConfig); + services.AddSingleton(wgApiConfig); services.AddSingleton(mongoConfig); + services.AddSingleton(gitHubOauthConfig); WotBlitzStatisticsLogicInstaller.ConfigureServices(services); services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy()) .AddMongoDb(mongoConfig.ConnectionString, tags: new[] { "services" }); - // Add GraphQL Services - services.AddGraphQLServer() + // GitHub authorization + services.AddHttpClient("github"); + services.AddTransient(); + services.AddAuthorization(options => + { + options.AddPolicy("GithubMbogaOnly", policy => + policy.RequireClaim(ClaimTypes.Name, gitHubOauthConfig.AllowedUser)); + }); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "https://WotBlitzStatisticsPro.com", + ValidAudience = "https://WotBlitzStatisticsPro.com", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(gitHubOauthConfig.ClientSecret!)) + }; + }); + + + // Add GraphQL Services + services.AddGraphQLServer() .AddQueryType(d => d.Name("Query")) .AddType() .AddType() .AddType() - .AddMutationType() + .AddMutationType(d => d.Name("Mutation")) + .AddType() + .AddType() .AddType() .AddType() .AddEnumType() .AddEnumType() - .AddEnumType(); + .AddEnumType() + .AddAuthorization() + .AddHttpRequestInterceptor( + (context, executor, builder, ct) => + { + // your code + return ValueTask.CompletedTask; + }) + ; + } - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - app + app .UseRouting() + .UseAuthorization() + .UseAuthentication() .UseEndpoints(endpoints => { endpoints.MapGraphQL(); diff --git a/WotBlitzStatisticsPro.GraphQl/WotBlitzStatisticsPro.GraphQl.csproj b/WotBlitzStatisticsPro.GraphQl/WotBlitzStatisticsPro.GraphQl.csproj index f8a0d72..6ef17d2 100644 --- a/WotBlitzStatisticsPro.GraphQl/WotBlitzStatisticsPro.GraphQl.csproj +++ b/WotBlitzStatisticsPro.GraphQl/WotBlitzStatisticsPro.GraphQl.csproj @@ -13,7 +13,10 @@ + + + diff --git a/WotBlitzStatisticsPro.GraphQl/appsettings.json b/WotBlitzStatisticsPro.GraphQl/appsettings.json index 8cd1d47..ad559c2 100644 --- a/WotBlitzStatisticsPro.GraphQl/appsettings.json +++ b/WotBlitzStatisticsPro.GraphQl/appsettings.json @@ -14,5 +14,9 @@ "Mongo": { "ConnectionString": "*", "DatabaseName": "*" - } + }, + "GitHubOAuth": { + "ClientId": "YourClientId", + "ClientSecret": "YourSecret" + } }