diff --git a/Domain/Entities/User.cs b/Domain/Entities/User.cs index 72903b0..a8c24a4 100644 --- a/Domain/Entities/User.cs +++ b/Domain/Entities/User.cs @@ -15,6 +15,7 @@ public User() public required string Email { get; set; } public required Provider Provider { get; set; } public required string ProviderKey { get; set; } + public string ProviderRefreshToken { get; set; } = null; public required string Name { get; set; } public bool IsActive { get; set; } public bool IsEmailVerified { get; set; } = false; diff --git a/IoCConfig/ConfigureServicesExtensions.cs b/IoCConfig/ConfigureServicesExtensions.cs index 217e311..367af54 100644 --- a/IoCConfig/ConfigureServicesExtensions.cs +++ b/IoCConfig/ConfigureServicesExtensions.cs @@ -1,23 +1,25 @@ -using Service; -using Domain.Models; -using Domain.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; using DataAccess; -using MongoDB.Driver; +using Domain.Models; using Domain.Repositories; +using Domain.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; -using Microsoft.IdentityModel.Tokens; -using System.Security.Cryptography; -using System; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; -using System.IO; -using System.Threading.Tasks; -using System.Text.Json; -using System.Security.Claims; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using MongoDB.Driver; +using Service; namespace IoCConfig { @@ -26,91 +28,123 @@ public static class ConfigureServicesExtensions public static void AddCustomCors(this IServiceCollection services, IConfiguration configuration) { services.AddCors(options => - options.AddPolicy("CorsPolicy", - builder => builder - .WithOrigins(configuration["Cors:Origins"]) - .AllowAnyMethod() - .AllowAnyHeader() - .AllowCredentials() - )); + options.AddPolicy( + "CorsPolicy", + builder => + builder + .WithOrigins(configuration["Cors:Origins"]) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + ) + ); } - public static void AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + public static void AddCustomAuthentication( + this IServiceCollection services, + IConfiguration configuration + ) { var rsa = RSA.Create(); - rsa.ImportRSAPrivateKey(Convert.FromBase64String(configuration["Jwt:PrivateKey"] ?? ""), out _); - services.AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; - }) - .AddCookie() - .AddGoogle(options => - { - options.ClientId = configuration["OAuth:GoogleClientId"] ?? ""; - options.ClientSecret = configuration["OAuth:GoogleClientSecret"] ?? ""; - options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.Events.OnCreatingTicket = ctx => + var scopes = configuration.GetSection("OAuth:Scopes").Get>() ?? new List(); + rsa.ImportRSAPrivateKey( + Convert.FromBase64String(configuration["Jwt:PrivateKey"] ?? ""), + out _ + ); + services + .AddAuthentication(options => { - if (ctx.User.TryGetProperty("picture", out var value)) + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddGoogle(options => + { + options.ClientId = configuration["OAuth:GoogleClientId"] ?? ""; + options.ClientSecret = configuration["OAuth:GoogleClientSecret"] ?? ""; + options.SaveTokens = true; + options.AccessType = "offline"; + foreach (string scope in scopes) { - ctx.Identity?.AddClaim(new Claim(ClaimTypes.Uri, value.ToString())); + options.Scope.Add(scope); } - return Task.CompletedTask; - }; - options.Events.OnRemoteFailure = ctx => - { - ctx.Response.Redirect(configuration["OAuth:GoogleCallbackURL"]); - ctx.HandleResponse(); - return Task.CompletedTask; - }; - }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = configuration["Jwt:Issuer"], - ValidAudience = configuration["Jwt:Audience"], - IssuerSigningKey = new RsaSecurityKey(rsa) - }; - - options.Events = new JwtBearerEvents - { - OnMessageReceived = context => + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.Events.OnCreatingTicket = ctx => { - if (context.Request.Cookies.TryGetValue(configuration["Jwt:CookieName"], out var encryptedToken)) + if (ctx.User.TryGetProperty("picture", out var value)) { - var dataProtector = context.HttpContext.RequestServices - .GetRequiredService() - .CreateProtector(configuration["Jwt:DataProtectionPurpose"]); + ctx.Identity?.AddClaim(new Claim(ClaimTypes.Uri, value.ToString())); + } + return Task.CompletedTask; + }; + options.Events.OnRemoteFailure = ctx => + { + ctx.Response.Redirect(configuration["OAuth:GoogleCallbackURL"]); + ctx.HandleResponse(); + return Task.CompletedTask; + }; + }) + .AddJwtBearer( + JwtBearerDefaults.AuthenticationScheme, + options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidAudience = configuration["Jwt:Audience"], + IssuerSigningKey = new RsaSecurityKey(rsa), + }; - try - { - var authCookie = JsonSerializer.Deserialize(dataProtector.Unprotect(encryptedToken)); - context.Token = authCookie.AccessToken; - } - catch + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => { - context.Fail("Invalid or tampered token"); - } - } + if ( + context.Request.Cookies.TryGetValue( + configuration["Jwt:CookieName"], + out var encryptedToken + ) + ) + { + var dataProtector = context + .HttpContext.RequestServices.GetRequiredService() + .CreateProtector(configuration["Jwt:DataProtectionPurpose"]); - return Task.CompletedTask; + try + { + var authCookie = JsonSerializer.Deserialize( + dataProtector.Unprotect(encryptedToken) + ); + context.Token = authCookie.AccessToken; + } + catch + { + context.Fail("Invalid or tampered token"); + } + } + + return Task.CompletedTask; + }, + }; } - }; - }); + ); } - public static void AddCustomDataProtection(this IServiceCollection services, IConfiguration configuration) + public static void AddCustomDataProtection( + this IServiceCollection services, + IConfiguration configuration + ) { - services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(configuration["Jwt:DataProtectionKeysPath"])) - .SetApplicationName(configuration["Jwt:DataProtectionApplicationName"]); + services + .AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(configuration["Jwt:DataProtectionKeysPath"])) + .SetApplicationName(configuration["Jwt:DataProtectionApplicationName"]); } + public static void AddCustomServices(this IServiceCollection services) { services.AddScoped(); @@ -121,7 +155,10 @@ public static void AddCustomServices(this IServiceCollection services) services.AddHttpContextAccessor(); } - public static void AddTurnstileService(this IServiceCollection services, IConfiguration configuration) + public static void AddTurnstileService( + this IServiceCollection services, + IConfiguration configuration + ) { var turnstileOptions = new TurnstileOptions(); configuration.GetSection("Turnstile").Bind(turnstileOptions); @@ -132,32 +169,43 @@ public static void AddTurnstileService(this IServiceCollection services, IConfig }); } - public static void AddCustomOptions(this IServiceCollection services, IConfiguration configuration) + public static void AddCustomOptions( + this IServiceCollection services, + IConfiguration configuration + ) { services.AddOptions().Bind(configuration.GetSection("Jwt")); services.AddOptions().Bind(configuration.GetSection("OAuth")); services.AddOptions().Bind(configuration.GetSection("SMTP")); services.AddOptions().Bind(configuration.GetSection("EmailTemplate")); - services.AddOptions().Bind(configuration.GetSection("DataProtection")); + services + .AddOptions() + .Bind(configuration.GetSection("DataProtection")); } public static void AddCustomSwagger(this IServiceCollection services) { - services.AddSwaggerGen(options => { - options.SwaggerDoc("v1", new OpenApiInfo - { - Title = "Micro IDP API Document", - Version = "v1" - }); + options.SwaggerDoc( + "v1", + new OpenApiInfo { Title = "Micro IDP API Document", Version = "v1" } + ); }); } - public static void AddCustomMongoDbService(this IServiceCollection services, IConfiguration configuration) + public static void AddCustomMongoDbService( + this IServiceCollection services, + IConfiguration configuration + ) { - services.AddSingleton(s => new MongoClient(configuration.GetConnectionString("MongoDb"))); - services.AddScoped(s => new MongoDbContext(s.GetRequiredService(), configuration["DbName"])); + services.AddSingleton(s => new MongoClient( + configuration.GetConnectionString("MongoDb") + )); + services.AddScoped(s => new MongoDbContext( + s.GetRequiredService(), + configuration["DbName"] + )); } } } diff --git a/Service/JwtTokenService.cs b/Service/JwtTokenService.cs index 34bba44..4e720bd 100644 --- a/Service/JwtTokenService.cs +++ b/Service/JwtTokenService.cs @@ -71,7 +71,9 @@ public JwtTokensData CreateJwtTokens(User user) new Claim(ClaimTypes.Name, user.Name, ClaimValueTypes.String, jwtIssuer), new Claim(ClaimTypes.Email, user.Email, ClaimValueTypes.String, jwtIssuer), new Claim(ClaimTypes.SerialNumber, user.SerialNumber, ClaimValueTypes.String, jwtIssuer), - new Claim(ClaimTypes.UserData, user.Id.ToString(), ClaimValueTypes.String, jwtIssuer) + new Claim(ClaimTypes.UserData, user.Id.ToString(), ClaimValueTypes.String, jwtIssuer), + new Claim("ProviderKey", user.ProviderKey, ClaimValueTypes.String, jwtIssuer), + new Claim("ProviderRefreshToken", user.ProviderRefreshToken, ClaimValueTypes.String, jwtIssuer) ]; foreach (Role role in user.Roles) diff --git a/WebApi/Controllers/AuthController.cs b/WebApi/Controllers/AuthController.cs index c763132..20bce76 100644 --- a/WebApi/Controllers/AuthController.cs +++ b/WebApi/Controllers/AuthController.cs @@ -247,6 +247,11 @@ public async Task> ForgotPassword(EmailViewMo return Ok(new ApiResponseViewModel { Success = true, Message = "reset_password_email_sent_successfully" }); } + if (user.Provider != Provider.Password) + { + return BadRequest(new ApiResponseViewModel { Success = false, Message = "password_reset_not_allowed_for_oauth_users" }); + } + var resetPasswordCode = new ResetPasswordCode { Email = user.Email, @@ -360,6 +365,7 @@ public IActionResult GoogleLogin() { RedirectUri = _oAuthOptions.GoogleCallbackURL }; + properties.Parameters.Add("prompt", "consent"); return Challenge(properties, GoogleDefaults.AuthenticationScheme); } @@ -373,11 +379,12 @@ public async Task>> Goo return BadRequest(new ApiResponseViewModel { Success = false, - Message = - "google_authentication_failed." + Message = "google_authentication_failed." }); } + var refreshToken = authenticateResult.Properties.GetTokenValue("refresh_token"); + var claims = authenticateResult.Principal.Claims; var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; @@ -401,6 +408,7 @@ public async Task>> Goo Email = email, ProviderKey = _securityService.GetSha256Hash(nameIdentifier), Provider = Provider.Google, + ProviderRefreshToken = refreshToken, IsActive = true, Roles = [new Role { Name = "User" }], IsEmailVerified = true, diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 981f2ec..2e8bdb6 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -1,4 +1,4 @@ -using IoCConfig; +using IoCConfig; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Serilog; @@ -56,4 +56,4 @@ app.UseSerilogRequestLogging(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/WebApi/ViewModels/AuthResponseViewModel.cs b/WebApi/ViewModels/AuthResponseViewModel.cs index 9fcc20f..35682c6 100644 --- a/WebApi/ViewModels/AuthResponseViewModel.cs +++ b/WebApi/ViewModels/AuthResponseViewModel.cs @@ -1,4 +1,4 @@ -namespace WebApi.ViewModels; +namespace WebApi.ViewModels; public class AuthResponseViewModel { diff --git a/WebApi/appsettings.json b/WebApi/appsettings.json index ab52ba6..c80f113 100644 --- a/WebApi/appsettings.json +++ b/WebApi/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Serilog": { "Using": ["Serilog.Sinks.Console", "Serilog.Formatting.Compact"], "MinimumLevel": { @@ -38,7 +38,8 @@ "OAuth": { "GoogleClientId": "", "GoogleClientSecret": "", - "GoogleCallbackURL": "" + "GoogleCallbackURL": "", + "Scopes": [""] }, "ConnectionStrings": { "MongoDb": "mongodb://USERNAME:PASSWORD@localhost:27017"