From e6e6c734715f36cc425f3579bdac88292bc3b190 Mon Sep 17 00:00:00 2001 From: D4VID Date: Mon, 17 Jun 2024 13:31:34 +0200 Subject: [PATCH] Working OAuth flow --- .idea/.idea.OAuthServer/.idea/dataSources.xml | 12 ++++ .idea/.idea.OAuthServer/.idea/sqldialects.xml | 6 ++ OAuthServer.sln.DotSettings.user | 2 + OAuthServer/AppDbContext.cs | 14 ++-- OAuthServer/Controllers/ExternalController.cs | 19 ++--- OAuthServer/Controllers/LoginController.cs | 33 +++------ OAuthServer/Controllers/OAuthController.cs | 72 ++++++++++++++++--- OAuthServer/Controllers/UserController.cs | 9 +-- OAuthServer/Program.cs | 48 +++++-------- OAuthServer/Properties/launchSettings.json | 4 +- OAuthServer/Services/JwtService.cs | 27 +++---- 11 files changed, 140 insertions(+), 106 deletions(-) create mode 100644 .idea/.idea.OAuthServer/.idea/dataSources.xml create mode 100644 .idea/.idea.OAuthServer/.idea/sqldialects.xml create mode 100644 OAuthServer.sln.DotSettings.user diff --git a/.idea/.idea.OAuthServer/.idea/dataSources.xml b/.idea/.idea.OAuthServer/.idea/dataSources.xml new file mode 100644 index 0000000..f96e37b --- /dev/null +++ b/.idea/.idea.OAuthServer/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/OAuthServer/db.sqlite3 + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/.idea.OAuthServer/.idea/sqldialects.xml b/.idea/.idea.OAuthServer/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/.idea/.idea.OAuthServer/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/OAuthServer.sln.DotSettings.user b/OAuthServer.sln.DotSettings.user new file mode 100644 index 0000000..e9e707c --- /dev/null +++ b/OAuthServer.sln.DotSettings.user @@ -0,0 +1,2 @@ + + ShowAndRun \ No newline at end of file diff --git a/OAuthServer/AppDbContext.cs b/OAuthServer/AppDbContext.cs index 6ca8802..0480ccb 100644 --- a/OAuthServer/AppDbContext.cs +++ b/OAuthServer/AppDbContext.cs @@ -4,20 +4,16 @@ using Microsoft.EntityFrameworkCore; namespace OAuthServer; -public class AppDbContext : IdentityDbContext -{ - public AppDbContext(DbContextOptions options) : base(options) - { - } +public class AppDbContext : IdentityDbContext { + public AppDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) - { + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Setup Identity roles modelBuilder.Entity().HasData( - new IdentityRole { Id = Guid.NewGuid().ToString(), Name = "User", NormalizedName = "USER" }, - new IdentityRole { Id = Guid.NewGuid().ToString(), Name = "External", NormalizedName = "EXTERNAL" } + new IdentityRole {Id = Guid.NewGuid().ToString(), Name = "User", NormalizedName = "USER"}, + new IdentityRole {Id = Guid.NewGuid().ToString(), Name = "External", NormalizedName = "EXTERNAL"} ); } } \ No newline at end of file diff --git a/OAuthServer/Controllers/ExternalController.cs b/OAuthServer/Controllers/ExternalController.cs index af583f0..5f559f4 100644 --- a/OAuthServer/Controllers/ExternalController.cs +++ b/OAuthServer/Controllers/ExternalController.cs @@ -6,27 +6,30 @@ namespace OAuthServer.Controllers; [ApiController] [Route("")] -public class ExternalController : ControllerBase -{ +public class ExternalController : ControllerBase { private readonly ILogger _logger; - public ExternalController(ILogger logger) - { + public ExternalController(ILogger logger) { _logger = logger; } [HttpPost] [Authorize(Policy = "External")] [Route("points")] - public ActionResult PostPoints(int points) - { + public ActionResult PostPoints(int points) { var id = HttpContext.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier); - if (id == null) - { + if (id == null) { return BadRequest(); } _logger.LogInformation("User {} got {} points", id.Value, points); return Ok(); } + + [HttpGet] + [Authorize(Policy = "External")] + [Route("user")] + public ActionResult GetUser() { + return Ok(new {UserId = 1}); + } } \ No newline at end of file diff --git a/OAuthServer/Controllers/LoginController.cs b/OAuthServer/Controllers/LoginController.cs index 2ec1c9d..d4caa13 100644 --- a/OAuthServer/Controllers/LoginController.cs +++ b/OAuthServer/Controllers/LoginController.cs @@ -7,13 +7,11 @@ namespace OAuthServer.Controllers; [ApiController] [Route("")] -public class LoginController : ControllerBase -{ +public class LoginController : ControllerBase { private readonly SignInManager _signInManager; private readonly UserManager _userManager; - public LoginController(SignInManager signInManager, UserManager userManager) - { + public LoginController(SignInManager signInManager, UserManager userManager) { _signInManager = signInManager; _userManager = userManager; } @@ -22,22 +20,18 @@ public class LoginController : ControllerBase [HttpPost] [Route("register")] - public async Task> Register([FromBody] RegisterRequest registerRequest) - { - IdentityUser user = new IdentityUser - { + public async Task> Register([FromBody] RegisterRequest registerRequest) { + IdentityUser user = new IdentityUser { UserName = registerRequest.Username, }; IdentityResult registerResult = await _userManager.CreateAsync(user, registerRequest.Password); - if (!registerResult.Succeeded) - { + if (!registerResult.Succeeded) { return BadRequest(registerResult); } IdentityResult roleResult = await _userManager.AddToRoleAsync(user, "User"); - if (!roleResult.Succeeded) - { + if (!roleResult.Succeeded) { throw new Exception($"Adding role User for {registerRequest.Username} not successful: {roleResult}"); } @@ -46,8 +40,7 @@ public class LoginController : ControllerBase [HttpGet] [Route("login")] - public ContentResult Login() - { + public ContentResult Login() { return Content(""" @@ -72,18 +65,15 @@ public class LoginController : ControllerBase [HttpPost] [Route("login")] - public async Task Login([FromForm] LoginRequest loginRequest, string? returnUrl) - { + public async Task Login([FromForm] LoginRequest loginRequest, string? returnUrl) { SignInResult result = await _signInManager.PasswordSignInAsync(loginRequest.Username, loginRequest.Password, isPersistent: true, lockoutOnFailure: false); - if (result.Succeeded) - { + if (result.Succeeded) { return Redirect(returnUrl ?? "/"); } - if (result.IsLockedOut) - { + if (result.IsLockedOut) { return Unauthorized("Account disabled"); } @@ -92,8 +82,7 @@ public class LoginController : ControllerBase [HttpPost] [Route("logout")] - public async Task Logout() - { + public async Task Logout() { await _signInManager.SignOutAsync(); return Ok("Successfully logged out"); } diff --git a/OAuthServer/Controllers/OAuthController.cs b/OAuthServer/Controllers/OAuthController.cs index 6c5141a..9a67766 100644 --- a/OAuthServer/Controllers/OAuthController.cs +++ b/OAuthServer/Controllers/OAuthController.cs @@ -1,24 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OAuthServer.Services; namespace OAuthServer.Controllers; [ApiController] -public class OAuthController : ControllerBase -{ +[Route("oauth")] +public class OAuthController : ControllerBase { private readonly ILogger _logger; private readonly JwtService _jwt; - public OAuthController(ILogger logger, JwtService jwt) - { + public OAuthController(ILogger logger, JwtService jwt) { _logger = logger; _jwt = jwt; } - [HttpPost] - [Route("get-token")] - public ActionResult GenerateToken() - { - return Ok(_jwt.GenerateToken()); + [Authorize] + [HttpGet("authorize")] + // ReSharper disable InconsistentNaming + public ActionResult Authorize( + [Required, Url] string redirect_uri, + string response_type, + string client_id, + string state + ) { + if (string.IsNullOrEmpty(response_type) || string.IsNullOrEmpty(client_id) || string.IsNullOrEmpty(state)) { + return Redirect($"{redirect_uri}?error=invalid_request"); + } + + if (response_type != "code") { + return Redirect($"{redirect_uri}?error=unsupported_response_type&state={state}"); + } + + if (client_id != "lmao") { + return Redirect($"{redirect_uri}?error=access_denied&error_description=Invalid+client+id&state={state}"); + } + + // TODO: generate code + string code = Guid.NewGuid().ToString(); + + return Redirect($"{redirect_uri}?code={code}&state={state}"); + } + + public record GenerateTokenRequest( + string? grant_type, + string? code, + string? redirect_uri, + string? client_id, + string? client_secret + ); + + [HttpPost("token")] + [Consumes("application/x-www-form-urlencoded")] + public ActionResult GenerateToken([FromForm] GenerateTokenRequest request) { + if (string.IsNullOrEmpty(request.grant_type) || string.IsNullOrEmpty(request.code) || + string.IsNullOrEmpty(request.redirect_uri) || + string.IsNullOrEmpty(request.client_id)) { + return BadRequest(new {error = "invalid_request"}); + } + + if (request.grant_type != "authorization_code") { + return BadRequest(new {error = "unsupported_grant_type"}); + } + + if (request.client_id != "lmao") { + return BadRequest(new {error = "invalid_client"}); + } + + string token = _jwt.GenerateToken(); + + Response.Headers.Append("Cache-Control", "no-store"); + Response.Headers.Append("Pragma", "no-cache"); + + return Ok(new {access_token = token, token_type = "bearer"}); } } \ No newline at end of file diff --git a/OAuthServer/Controllers/UserController.cs b/OAuthServer/Controllers/UserController.cs index 01ce915..ab7f012 100644 --- a/OAuthServer/Controllers/UserController.cs +++ b/OAuthServer/Controllers/UserController.cs @@ -5,15 +5,12 @@ namespace OAuthServer.Controllers; [ApiController] [Route("")] -public class UserController : ControllerBase -{ - +public class UserController : ControllerBase { [HttpGet] [Authorize(Policy = "User")] - [Route("user")] - public ActionResult GetUser() + [Route("/auth/user")] + public ActionResult TestAuth() { - return Ok("Authorized as User"); } } \ No newline at end of file diff --git a/OAuthServer/Program.cs b/OAuthServer/Program.cs index 3286fa9..c3d57ac 100644 --- a/OAuthServer/Program.cs +++ b/OAuthServer/Program.cs @@ -16,29 +16,23 @@ builder.Logging.AddConsole(); // Add services to the container. builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => -{ +builder.Services.AddSwaggerGen(options => { // Create a authentication schema for JWT tokens - options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.Http, Scheme = "bearer", - Reference = new OpenApiReference - { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }); - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { + options.AddSecurityRequirement(new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { + new OpenApiSecurityScheme { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } @@ -58,11 +52,9 @@ builder.Services.AddIdentity(options => { options.St var rsaKey = JwtService.GetSigningKey(); // Add the JWT authentication method -builder.Services.AddAuthentication().AddJwtBearer("OAuthToken", options => -{ +builder.Services.AddAuthentication().AddJwtBearer("OAuthToken", options => { options.SaveToken = false; - options.TokenValidationParameters = new TokenValidationParameters() - { + options.TokenValidationParameters = new TokenValidationParameters() { ValidateIssuer = false, ValidateAudience = false, RequireSignedTokens = true, @@ -70,8 +62,7 @@ builder.Services.AddAuthentication().AddJwtBearer("OAuthToken", options => }; }); -builder.Services.Configure(options => -{ +builder.Services.Configure(options => { // SignIn settings. options.SignIn.RequireConfirmedAccount = false; options.SignIn.RequireConfirmedEmail = false; @@ -91,8 +82,7 @@ builder.Services.Configure(options => options.Password.RequiredUniqueChars = 1; }); -builder.Services.ConfigureApplicationCookie(options => -{ +builder.Services.ConfigureApplicationCookie(options => { // Cookie options options.Cookie.Name = "AuthCookie"; options.Cookie.HttpOnly = true; @@ -105,23 +95,20 @@ builder.Services.ConfigureApplicationCookie(options => }); // Force Identity's security stamp to be validated every minute. -builder.Services.Configure(options => -{ +builder.Services.Configure(options => { options.ValidationInterval = TimeSpan.FromMinutes(10); }); // Set a more secure password hashing iteration count builder.Services.Configure(option => { option.IterationCount = 100_000; }); -builder.Services.AddDataProtection().UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration() -{ +builder.Services.AddDataProtection().UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration() { EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC, ValidationAlgorithm = ValidationAlgorithm.HMACSHA256 }); // Add policy-based authorization -builder.Services.AddAuthorization(options => -{ +builder.Services.AddAuthorization(options => { // Require either role to authenticate as Contestant options.AddPolicy("User", policy => policy .RequireRole("User") @@ -140,8 +127,7 @@ builder.Services.AddSingleton(); var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ +if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } @@ -150,10 +136,8 @@ app.MapControllers(); // Automatically apply migrations to database on startup var scopeFactory = app.Services.GetRequiredService(); -using (var scope = scopeFactory.CreateScope()) -{ - using (var databaseContext = scope.ServiceProvider.GetRequiredService()) - { +using (var scope = scopeFactory.CreateScope()) { + using (var databaseContext = scope.ServiceProvider.GetRequiredService()) { // Migrate the database databaseContext.Database.Migrate(); } diff --git a/OAuthServer/Properties/launchSettings.json b/OAuthServer/Properties/launchSettings.json index 72a93d3..8b4801f 100644 --- a/OAuthServer/Properties/launchSettings.json +++ b/OAuthServer/Properties/launchSettings.json @@ -12,9 +12,9 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", - "applicationUrl": "http://localhost:5196", + "applicationUrl": "http://localhost:1234", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/OAuthServer/Services/JwtService.cs b/OAuthServer/Services/JwtService.cs index 3b36d15..1c00955 100644 --- a/OAuthServer/Services/JwtService.cs +++ b/OAuthServer/Services/JwtService.cs @@ -5,27 +5,21 @@ using Microsoft.IdentityModel.Tokens; namespace OAuthServer.Services; -public class JwtService -{ +public class JwtService { private readonly RSA _rsaKey; - - public JwtService() - { + + public JwtService() { _rsaKey = GetSigningKey(); } - public static RSA GetSigningKey() - { + public static RSA GetSigningKey() { RSA rsaKey = RSA.Create(); const string jwtKeyPath = ".aspnet/jwt-key"; string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string fullPath = Path.Combine(home, jwtKeyPath); - if (File.Exists(fullPath)) - { + if (File.Exists(fullPath)) { rsaKey.ImportRSAPrivateKey(File.ReadAllBytes(fullPath), out _); - } - else - { + } else { string? dirName = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dirName)) Directory.CreateDirectory(dirName); @@ -37,14 +31,11 @@ public class JwtService return rsaKey; } - public string GenerateToken() - { + public string GenerateToken() { var handler = new JsonWebTokenHandler(); var key = new RsaSecurityKey(_rsaKey); - var token = handler.CreateToken(new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(new[] - { + var token = handler.CreateToken(new SecurityTokenDescriptor { + Subject = new ClaimsIdentity(new[] { new Claim(JwtRegisteredClaimNames.Sub, "user1"), new Claim("role", "External"), new Claim("scope", "scope:1")