diff --git a/Authorization.API.ServiceDefaults/Authorization.API.ServiceDefaults.csproj b/Authorization.API.ServiceDefaults/Authorization.API.ServiceDefaults.csproj index 24b1b4f..6a72f8e 100644 --- a/Authorization.API.ServiceDefaults/Authorization.API.ServiceDefaults.csproj +++ b/Authorization.API.ServiceDefaults/Authorization.API.ServiceDefaults.csproj @@ -12,11 +12,11 @@ - - - - - + + + + + diff --git a/Authorization.API.Tests/Authorization.API.Tests.csproj b/Authorization.API.Tests/Authorization.API.Tests.csproj new file mode 100644 index 0000000..820c448 --- /dev/null +++ b/Authorization.API.Tests/Authorization.API.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Authorization.API.Tests/EncryptionServiceTests.cs b/Authorization.API.Tests/EncryptionServiceTests.cs new file mode 100644 index 0000000..e4cfbd0 --- /dev/null +++ b/Authorization.API.Tests/EncryptionServiceTests.cs @@ -0,0 +1,39 @@ +using Authorization.API.Services; + +namespace Authorization.API.Tests; + +public class EncryptionServiceTests +{ + private const string DevKey = "TG9jYWxEZXYtQUVTMjU2LUVuY3J5cHRpb24tS2V5cyE="; + + [Fact] + public void EncryptDecrypt_RoundTrips() + { + var service = new EncryptionService(DevKey); + const string original = "authorization-code"; + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.NotEqual(original, encrypted); + Assert.Equal(original, decrypted); + } + + [Fact] + public void Encrypt_UsesUniqueCipherText() + { + var service = new EncryptionService(DevKey); + + var first = service.Encrypt("same-value"); + var second = service.Encrypt("same-value"); + + Assert.NotEqual(first, second); + } + + [Fact] + public void Constructor_RejectsInvalidKeyLength() + { + var shortKey = Convert.ToBase64String("tooshort"u8.ToArray()); + Assert.Throws(() => new EncryptionService(shortKey)); + } +} diff --git a/Authorization.API.Tests/GlobalUsings.cs b/Authorization.API.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Authorization.API.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Authorization.API.Tests/PkceHelperTests.cs b/Authorization.API.Tests/PkceHelperTests.cs new file mode 100644 index 0000000..9e253a1 --- /dev/null +++ b/Authorization.API.Tests/PkceHelperTests.cs @@ -0,0 +1,31 @@ +using Authorization.API.Helpers; + +namespace Authorization.API.Tests; + +public class PkceHelperTests +{ + [Fact] + public void ComputeCodeChallenge_UsesS256() + { + const string verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + var challenge = PkceHelper.ComputeCodeChallenge(verifier, PkceHelper.S256); + + Assert.Equal("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge); + Assert.True(PkceHelper.Validate(verifier, challenge, PkceHelper.S256)); + } + + [Fact] + public void ComputeCodeChallenge_RejectsPlainMethod() + { + Assert.Throws(() => PkceHelper.ComputeCodeChallenge("verifier", "plain")); + Assert.False(PkceHelper.Validate("verifier", "verifier", "plain")); + } + + [Fact] + public void Validate_AllowsMissingChallengeWhenVerifierMissing() + { + Assert.True(PkceHelper.Validate(null, null, null)); + Assert.False(PkceHelper.Validate("verifier", null, PkceHelper.S256)); + Assert.False(PkceHelper.Validate(null, "challenge", PkceHelper.S256)); + } +} diff --git a/Authorization.API.Tests/TokenHelperTests.cs b/Authorization.API.Tests/TokenHelperTests.cs new file mode 100644 index 0000000..4f6439f --- /dev/null +++ b/Authorization.API.Tests/TokenHelperTests.cs @@ -0,0 +1,39 @@ +using Authorization.API.Helpers; + +namespace Authorization.API.Tests; + +public class TokenHelperTests +{ + [Fact] + public void HashToken_IsDeterministicAndNotReversible() + { + var token = "refresh-token-value"; + + var first = TokenHelper.HashToken(token); + var second = TokenHelper.HashToken(token); + + Assert.Equal(first, second); + Assert.NotEqual(token, first); + Assert.Equal(64, first.Length); + } + + [Fact] + public void GenerateSecureCode_IsUrlSafe() + { + var code = TokenHelper.GenerateSecureCode(); + + Assert.False(string.IsNullOrWhiteSpace(code)); + Assert.DoesNotContain("+", code); + Assert.DoesNotContain("/", code); + Assert.DoesNotContain("=", code); + } + + [Fact] + public void SecretsEqual_ComparesExactValues() + { + Assert.True(TokenHelper.SecretsEqual("secret", "secret")); + Assert.False(TokenHelper.SecretsEqual("secret", "Secret")); + Assert.False(TokenHelper.SecretsEqual("secret", "other")); + Assert.False(TokenHelper.SecretsEqual(null, "secret")); + } +} diff --git a/Authorization.API.sln b/Authorization.API.sln index 30d516b..e4b3f3a 100644 --- a/Authorization.API.sln +++ b/Authorization.API.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.12.35506.116 d17.12 +VisualStudioVersion = 17.12.35506.116 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.API", "Authorization.API\Authorization.API.csproj", "{B1FF94A3-76F2-456A-8718-DA49C5F99668}" EndProject @@ -9,24 +9,66 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.API.AppHost", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.API.ServiceDefaults", "Authorization.API.ServiceDefaults\Authorization.API.ServiceDefaults.csproj", "{C104DBFB-51D8-40D9-995B-2DE4A342E10A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.API.Tests", "Authorization.API.Tests\Authorization.API.Tests.csproj", "{C5B82DC1-C9A9-4805-A89C-491567728285}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|x64.Build.0 = Debug|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Debug|x86.Build.0 = Debug|Any CPU {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|Any CPU.Build.0 = Release|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|x64.ActiveCfg = Release|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|x64.Build.0 = Release|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|x86.ActiveCfg = Release|Any CPU + {B1FF94A3-76F2-456A-8718-DA49C5F99668}.Release|x86.Build.0 = Release|Any CPU {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|x64.Build.0 = Debug|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Debug|x86.Build.0 = Debug|Any CPU {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|Any CPU.ActiveCfg = Release|Any CPU {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|Any CPU.Build.0 = Release|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|x64.ActiveCfg = Release|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|x64.Build.0 = Release|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|x86.ActiveCfg = Release|Any CPU + {7837B4C0-FC78-4116-8727-A0BF147F459A}.Release|x86.Build.0 = Release|Any CPU {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|x64.Build.0 = Debug|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Debug|x86.Build.0 = Debug|Any CPU {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|Any CPU.ActiveCfg = Release|Any CPU {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|Any CPU.Build.0 = Release|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|x64.ActiveCfg = Release|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|x64.Build.0 = Release|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|x86.ActiveCfg = Release|Any CPU + {C104DBFB-51D8-40D9-995B-2DE4A342E10A}.Release|x86.Build.0 = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|x64.ActiveCfg = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|x64.Build.0 = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Debug|x86.Build.0 = Debug|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|Any CPU.Build.0 = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|x64.ActiveCfg = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|x64.Build.0 = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|x86.ActiveCfg = Release|Any CPU + {C5B82DC1-C9A9-4805-A89C-491567728285}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Authorization.API/Authorization.API.csproj b/Authorization.API/Authorization.API.csproj index 4b02f36..fabd9a6 100644 --- a/Authorization.API/Authorization.API.csproj +++ b/Authorization.API/Authorization.API.csproj @@ -11,7 +11,7 @@ - + diff --git a/Authorization.API/Authorization.API.http b/Authorization.API/Authorization.API.http index 091dca5..56ce5c3 100644 --- a/Authorization.API/Authorization.API.http +++ b/Authorization.API/Authorization.API.http @@ -1,6 +1,36 @@ @Authorization.API_HostAddress = http://localhost:5215 -GET {{Authorization.API_HostAddress}}/weatherforecast/ -Accept: application/json +### Register a user +POST {{Authorization.API_HostAddress}}/account/register +Content-Type: application/json -### +{ + "username": "demo", + "email": "demo@example.com", + "password": "Password1!" +} + +### Password login (returns access and refresh tokens) +POST {{Authorization.API_HostAddress}}/account/login/token +Content-Type: application/json + +{ + "email": "demo@example.com", + "password": "Password1!" +} + +### Current user +GET {{Authorization.API_HostAddress}}/account/me +Authorization: Bearer {{access_token}} + +### Client credentials +POST {{Authorization.API_HostAddress}}/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=demo-client&client_secret=demo-secret&scope=api + +### Authorization-code token exchange +POST {{Authorization.API_HostAddress}}/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code&client_id=demo-client&client_secret=demo-secret&code={{code}}&redirect_uri=https://localhost/callback&code_verifier={{code_verifier}} diff --git a/Authorization.API/Context/ApplicationDbContext.cs b/Authorization.API/Context/ApplicationDbContext.cs index 9717bc5..bf60a2e 100644 --- a/Authorization.API/Context/ApplicationDbContext.cs +++ b/Authorization.API/Context/ApplicationDbContext.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace Authorization.API.Context; + public class ApplicationDbContext : IdentityDbContext { public ApplicationDbContext(DbContextOptions options) @@ -11,59 +12,56 @@ public ApplicationDbContext(DbContextOptions options) { } - // OAuth Clients public DbSet Clients { get; set; } - // OAuth Authorization Codes public DbSet AuthorizationCodes { get; set; } - // OAuth Refresh Tokens public DbSet RefreshTokens { get; set; } - // API Scopes public DbSet ApiScopes { get; set; } - // API Resources public DbSet ApiResources { get; set; } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); - // Configure Identity Tables builder.Entity() .ToTable("Users"); builder.Entity() .ToTable("Roles"); - // Configure OAuth Clients builder.Entity() .HasIndex(c => c.ClientId) .IsUnique(); - // Configure Authorization Codes builder.Entity() .HasIndex(ac => ac.Code) - .IsUnique(); // Ensures each code is unique + .IsUnique(); builder.Entity() .HasIndex(ac => new { ac.ClientId, ac.UserId }); - // Configure Refresh Tokens builder.Entity() .HasIndex(r => r.Token) .IsUnique(); builder.Entity() .HasOne(rt => rt.User) - .WithMany() // Each user can have many refresh tokens - .HasForeignKey(rt => rt.UserId); + .WithMany() + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(rt => rt.Client) + .WithMany() + .HasPrincipalKey(c => c.ClientId) + .HasForeignKey(rt => rt.ClientId) + .OnDelete(DeleteBehavior.Cascade); - // Configure API Scopes builder.Entity() .HasIndex(s => s.Name) .IsUnique(); } } - diff --git a/Authorization.API/Controllers/AccountApiController.cs b/Authorization.API/Controllers/AccountApiController.cs new file mode 100644 index 0000000..8fb6dd7 --- /dev/null +++ b/Authorization.API/Controllers/AccountApiController.cs @@ -0,0 +1,172 @@ +using System.Security.Claims; +using Authorization.API.Models; +using Authorization.API.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Authorization.API.Controllers; + +[ApiController] +[Route("account")] +public class AccountApiController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ITokenService _tokenService; + private readonly IClientService _clientService; + + public AccountApiController( + UserManager userManager, + SignInManager signInManager, + ITokenService tokenService, + IClientService clientService) + { + _userManager = userManager; + _signInManager = signInManager; + _tokenService = tokenService; + _clientService = clientService; + } + + [HttpPost("login/token")] + [ProducesResponseType(typeof(TokenResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task LoginToken([FromBody] LoginModel model) + { + var user = await _userManager.FindByEmailAsync(model.Email); + if (user == null) + { + return Unauthorized("Invalid email or password."); + } + + var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); + if (!result.Succeeded) + { + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, "User account is locked out. Try again later."); + } + + return Unauthorized("Invalid email or password."); + } + + var accessToken = _tokenService.GenerateJwtToken(user); + var refreshToken = _tokenService.GenerateRefreshToken(); + await _tokenService.StoreRefreshToken(refreshToken, user.Id); + + return Ok(new TokenResponse + { + AccessToken = accessToken, + RefreshToken = refreshToken, + TokenType = "Bearer", + ExpiresIn = _tokenService.GetAccessTokenExpirySeconds() + }); + } + + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [HttpPost("logout")] + public async Task Logout() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + { + return Unauthorized("User not found."); + } + + await _tokenService.RevokeUserRefreshTokens(userId); + await _signInManager.SignOutAsync(); + + return Ok(new { message = "Logged out successfully" }); + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterModel model) + { + var user = new ApplicationUser { UserName = model.Username, Email = model.Email }; + var result = await _userManager.CreateAsync(user, model.Password); + + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + return Ok(new { message = "User registered successfully" }); + } + + [HttpPost("refresh")] + [ProducesResponseType(typeof(TokenResponse), StatusCodes.Status200OK)] + public async Task RefreshToken([FromBody] RefreshTokenRequest model) + { + if (string.IsNullOrWhiteSpace(model.UserId) && string.IsNullOrWhiteSpace(model.ClientId)) + { + return BadRequest("User ID or Client ID is required."); + } + + if (string.IsNullOrWhiteSpace(model.RefreshToken)) + { + return BadRequest("Refresh token is required."); + } + + ApplicationUser? user = null; + if (!string.IsNullOrWhiteSpace(model.UserId)) + { + user = await _userManager.FindByIdAsync(model.UserId); + if (user == null) + { + return BadRequest("User is not valid."); + } + } + + Client? client = null; + if (!string.IsNullOrWhiteSpace(model.ClientId)) + { + client = await _clientService.GetClientById(model.ClientId); + if (client == null) + { + return Unauthorized("Client is not valid."); + } + } + + var tokenIsValid = await _tokenService.ValidateRefreshToken(model.RefreshToken, model.UserId, model.ClientId); + if (!tokenIsValid) + { + return Unauthorized("Invalid refresh token."); + } + + await _tokenService.RevokeRefreshToken(model.RefreshToken, model.UserId, model.ClientId); + + var newAccessToken = user == null + ? _tokenService.GenerateJwtToken(client!) + : _tokenService.GenerateJwtToken(user); + var newRefreshToken = _tokenService.GenerateRefreshToken(); + + await _tokenService.StoreRefreshToken(newRefreshToken, user?.Id, client?.ClientId); + + return Ok(new TokenResponse + { + AccessToken = newAccessToken, + RefreshToken = newRefreshToken, + TokenType = "Bearer", + ExpiresIn = _tokenService.GetAccessTokenExpirySeconds() + }); + } + + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [HttpGet("me")] + public async Task GetUserInfo() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound("User not found."); + } + + return Ok(new + { + id = user.Id, + username = user.UserName, + email = user.Email + }); + } +} diff --git a/Authorization.API/Controllers/AccountController.cs b/Authorization.API/Controllers/AccountController.cs index cfe39de..4a7a25e 100644 --- a/Authorization.API/Controllers/AccountController.cs +++ b/Authorization.API/Controllers/AccountController.cs @@ -1,15 +1,9 @@ using Authorization.API.Models; using Authorization.API.Services; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using System.Threading.Tasks; namespace Authorization.API.Controllers; @@ -18,174 +12,63 @@ public class AccountController : Controller { private readonly UserManager _userManager; private readonly SignInManager _signInManager; - private readonly IConfiguration _config; - private readonly TokenService _tokenService; - private readonly IClientService _clientService; - public AccountController(UserManager userManager, SignInManager signInManager, IConfiguration config, TokenService tokenService, IClientService clientService) + public AccountController( + UserManager userManager, + SignInManager signInManager) { _userManager = userManager; _signInManager = signInManager; - _config = config; - _tokenService = tokenService; - _clientService = clientService; } - /// - /// Displays the login page. - /// [HttpGet("login")] + [ApiExplorerSettings(IgnoreApi = true)] public IActionResult Login(string? returnUrl = null) { - ViewData["ReturnUrl"] = returnUrl; - return View(); // Returns a Razor View (if applicable) + return View(new LoginModel { ReturnUrl = returnUrl }); } - /// - /// Handles user login and sets authentication cookies. - /// [HttpPost("login")] - public async Task Login([FromBody] LoginModel model) + [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task Login(LoginModel model) { if (!ModelState.IsValid) - return BadRequest(ModelState); - - var user = await _userManager.FindByEmailAsync(model.Email); - if (user == null || !await _userManager.CheckPasswordAsync(user, model.Password)) - return Unauthorized("Invalid email or password"); - - var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); - - if (!result.Succeeded) { - if (result.IsLockedOut) - return Forbid("User account is locked out. Try again later."); - if (result.RequiresTwoFactor) - return Unauthorized("Two-factor authentication is required."); - return Unauthorized("Invalid email or password."); + return View(model); } - // Use TokenService to generate JWT - var token = _tokenService.GenerateJwtToken(user); - var refreshToken = _tokenService.GenerateRefreshToken(); - - return Ok(new - { - access_token = token, - refresh_token = refreshToken, - token_type = "Bearer", - expires_in = 3600 - }); - } - - /// - /// Logs out the user and removes authentication cookies. - /// - [Authorize] - [HttpPost("logout")] - public async Task Logout() - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (userId == null) - return Unauthorized("User not found"); - - var user = await _userManager.FindByIdAsync(userId); + var user = await _userManager.FindByEmailAsync(model.Email); if (user == null) - return Unauthorized("Invalid user"); - - // Revoke refresh token - await _tokenService.RevokeUserRefreshTokens(user.Id); - - // Sign out the user (for cookie-based auth) - await _signInManager.SignOutAsync(); - - return Ok(new { message = "Logged out successfully" }); - } - - /// - /// Registers a new user. - /// - [HttpPost("register")] - public async Task Register([FromBody] RegisterModel model) - { - if (!ModelState.IsValid) - return BadRequest(ModelState); - - var user = new ApplicationUser { UserName = model.Username, Email = model.Email }; - var result = await _userManager.CreateAsync(user, model.Password); - - if (!result.Succeeded) - return BadRequest(result.Errors); - - return Ok("User registered successfully"); - } - - [HttpPost("refresh")] - public async Task RefreshToken([FromBody] RefreshTokenRequest model) - { - if (string.IsNullOrWhiteSpace(model.UserId) && string.IsNullOrWhiteSpace(model.ClientId)) - { - return BadRequest("User ID or Client ID is required"); - } - - if (string.IsNullOrWhiteSpace(model.RefreshToken)) - { - return BadRequest("Refresh token is required"); - } - - ApplicationUser? user = null; - if (!string.IsNullOrWhiteSpace(model.UserId)) { - user = await _userManager.FindByIdAsync(model.UserId); - if (user == null) - return BadRequest("User is not valid."); + ModelState.AddModelError(string.Empty, "Invalid email or password."); + return View(model); } - Client? client = null; - if (!string.IsNullOrWhiteSpace(model.ClientId)) + var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); + if (!result.Succeeded) { - client = await _clientService.GetClientById(model.ClientId!); - if (client == null) - return Unauthorized("Client is not valid."); + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, "User account is locked out. Try again later."); + } + else if (result.RequiresTwoFactor) + { + ModelState.AddModelError(string.Empty, "Two-factor authentication is required."); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid email or password."); + } + + return View(model); } - var tokenIsValid = await _tokenService.ValidateRefreshToken(model.RefreshToken, model.UserId, model.ClientId); - - if (!tokenIsValid) + if (!string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)) { - return Unauthorized("Invalid refresh token"); + return Redirect(model.ReturnUrl); } - var newAccessToken = user == null ? _tokenService.GenerateJwtToken(client!) : _tokenService.GenerateJwtToken(user); - var newRefreshToken = _tokenService.GenerateRefreshToken(); - - await _tokenService.StoreRefreshToken(newRefreshToken, user?.Id, client?.ClientId); - - return Ok(new - { - access_token = newAccessToken, - refresh_token = newRefreshToken, - token_type = "Bearer", - expires_in = 3600 - }); - } - - /// - /// Gets the currently logged-in user's information. - /// - [Authorize] - [HttpGet("me")] - public async Task GetUserInfo() - { - var user = await _userManager.GetUserAsync(User); - if (user == null) - return NotFound("User not found"); - - return Ok(new - { - id = user.Id, - username = user.UserName, - email = user.Email - }); + return Redirect("/scalar"); } } diff --git a/Authorization.API/Controllers/AuthorizeController.cs b/Authorization.API/Controllers/AuthorizeController.cs new file mode 100644 index 0000000..3933b8e --- /dev/null +++ b/Authorization.API/Controllers/AuthorizeController.cs @@ -0,0 +1,131 @@ +using Authorization.API.Helpers; +using Authorization.API.Models; +using Authorization.API.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; + +namespace Authorization.API.Controllers; + +[Route("authorize")] +public class AuthorizeController : Controller +{ + private readonly IClientService _clientService; + private readonly ITokenService _tokenService; + private readonly UserManager _userManager; + + public AuthorizeController( + IClientService clientService, + ITokenService tokenService, + UserManager userManager) + { + _clientService = clientService; + _tokenService = tokenService; + _userManager = userManager; + } + + [HttpGet] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task Authorize( + [FromQuery] string response_type, + [FromQuery] string client_id, + [FromQuery] string redirect_uri, + [FromQuery] string? scope, + [FromQuery] string? state, + [FromQuery] string? code_challenge, + [FromQuery] string? code_challenge_method) + { + if (response_type != "code") + { + return BadRequest("Invalid response type. Only 'code' is supported."); + } + + var client = await _clientService.GetClientById(client_id); + if (client == null) + { + return BadRequest("Invalid client."); + } + + if (string.IsNullOrWhiteSpace(redirect_uri) + || !Uri.TryCreate(redirect_uri, UriKind.Absolute, out _) + || !string.Equals(client.RedirectUri, redirect_uri, StringComparison.Ordinal)) + { + return BadRequest("Invalid redirect URI."); + } + + if (client.RequirePkce && string.IsNullOrWhiteSpace(code_challenge)) + { + return RedirectWithError(redirect_uri, "invalid_request", "PKCE is required.", state); + } + + if (!string.IsNullOrWhiteSpace(code_challenge) + && !string.Equals(code_challenge_method, PkceHelper.S256, StringComparison.Ordinal)) + { + return RedirectWithError(redirect_uri, "invalid_request", "Only S256 PKCE is supported.", state); + } + + var cookieAuth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (!cookieAuth.Succeeded) + { + var challengeProperties = new AuthenticationProperties + { + RedirectUri = QueryHelpers.AddQueryString("/authorize", new Dictionary + { + ["response_type"] = response_type, + ["client_id"] = client_id, + ["redirect_uri"] = redirect_uri, + ["scope"] = scope, + ["state"] = state, + ["code_challenge"] = code_challenge, + ["code_challenge_method"] = code_challenge_method + }) + }; + + return Challenge(challengeProperties, IdentityConstants.ApplicationScheme); + } + + var user = await _userManager.GetUserAsync(cookieAuth.Principal); + if (user == null) + { + return Unauthorized("User is not authenticated."); + } + + var authorizationCode = await _tokenService.GenerateAuthorizationCode( + client_id, + user.Id, + user.Id, + redirect_uri, + scope, + code_challenge, + code_challenge_method); + + var parameters = new Dictionary + { + ["code"] = authorizationCode + }; + + if (!string.IsNullOrEmpty(state)) + { + parameters["state"] = state; + } + + return Redirect(QueryHelpers.AddQueryString(redirect_uri, parameters)); + } + + private IActionResult RedirectWithError(string redirectUri, string error, string description, string? state) + { + var parameters = new Dictionary + { + ["error"] = error, + ["error_description"] = description + }; + + if (!string.IsNullOrEmpty(state)) + { + parameters["state"] = state; + } + + return Redirect(QueryHelpers.AddQueryString(redirectUri, parameters)); + } +} diff --git a/Authorization.API/Controllers/TokenController.cs b/Authorization.API/Controllers/TokenController.cs index 0d8d10d..4767726 100644 --- a/Authorization.API/Controllers/TokenController.cs +++ b/Authorization.API/Controllers/TokenController.cs @@ -2,347 +2,194 @@ using Authorization.API.Helpers; using Authorization.API.Models; using Authorization.API.Services; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; namespace Authorization.API.Controllers; -/// -/// The token controller. -/// [ApiController] - +[Route("token")] public class TokenController : ControllerBase { - /// - /// The context. - /// private readonly ApplicationDbContext _context; - /// - /// The encryption service. - /// - private readonly EncryptionService _encryptionService; - /// - /// The token service. - /// - private readonly TokenService _tokenService; - - /// - /// Initializes a new instance of the class. - /// - /// The context. - /// The encryption service. - /// The token service. - public TokenController(ApplicationDbContext context, EncryptionService encryptionService, TokenService tokenService) + private readonly ITokenService _tokenService; + + public TokenController(ApplicationDbContext context, ITokenService tokenService) { _context = context; - _encryptionService = encryptionService; _tokenService = tokenService; } - /// - /// OAuth 2.0 Authorization Endpoint (Step 1) - /// Client requests an authorization code with optional PKCE. - /// - - [HttpGet("authorize")] - public async Task Authorize( - [FromQuery] string response_type, - [FromQuery] string client_id, - [FromQuery] string redirect_uri, - [FromQuery] string? code_challenge, - [FromQuery] string? code_challenge_method, - [FromQuery] string state) + [HttpPost] + [Consumes("application/x-www-form-urlencoded")] + public async Task ExchangeToken([FromForm] TokenRequest request) { - // Check if the user is authenticated (from cookies/session) - if (!User.Identity?.IsAuthenticated ?? true) + return request.GrantType switch { - return Challenge(new AuthenticationProperties - { - RedirectUri = Url.Action(nameof(Authorize), new - { - response_type, - client_id, - redirect_uri, - code_challenge, - code_challenge_method, - state - }) - }); - } - - // Extract authenticated user ID - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - return Unauthorized("User is not authenticated"); - - // Validate Client - var client = await _context.Clients.FirstOrDefaultAsync(c => c.ClientId == client_id); - if (client == null) return BadRequest("Invalid client"); - - if (response_type != "code") return BadRequest("Invalid response type"); - if (client.RedirectUri != redirect_uri) return BadRequest("Invalid redirect URI"); - - // Generate Authorization Code - var authorizationCode = await _tokenService.GenerateAuthorizationCode(client_id, userId, code_challenge, code_challenge_method); - - // Redirect back to the client with the authorization code - var redirectUrl = $"{redirect_uri}?code={authorizationCode}&state={state}"; - return Redirect(redirectUrl); + "authorization_code" => await HandleAuthorizationCodeGrant(request), + "refresh_token" => await HandleRefreshTokenGrant(request), + "client_credentials" => await HandleClientCredentialsGrant(request), + _ => BadRequest(new { error = "unsupported_grant_type" }) + }; } - - /// - /// Exchanges the token. - /// - /// The request. - /// ]]> - [HttpPost] - [Route("token")] - public async Task ExchangeToken([FromForm] TokenRequest request) + private async Task HandleAuthorizationCodeGrant(TokenRequest request) { - switch (request.GrantType) + var client = await ValidateClientAsync(request); + if (client is null) { - case "authorization_code": - return await HandleAuthorizationCodeGrant(request); + return Unauthorized(new { error = "invalid_client" }); + } - case "refresh_token": - return await HandleRefreshTokenGrant(request); + if (string.IsNullOrWhiteSpace(request.Code)) + { + return BadRequest(new { error = "invalid_request", error_description = "code is required." }); + } - case "client_credentials": - return await HandleClientCredentialsGrant(request); + var hashedCode = TokenHelper.HashToken(request.Code); + var authCode = await _context.AuthorizationCodes + .SingleOrDefaultAsync(c => c.Code == hashedCode && c.ClientId == request.ClientId); - default: - return BadRequest("Unsupported grant type."); + if (authCode == null || authCode.IsUsed || authCode.ExpiresAt <= DateTime.UtcNow) + { + return BadRequest(new { error = "invalid_grant", error_description = "Invalid or expired authorization code." }); } - } - /// - /// Handle authorization code grant. - /// - /// The request. - /// ]]> - private async Task HandleAuthorizationCodeGrant(TokenRequest request) - { - // Validate client - var client = await _context.Clients.SingleOrDefaultAsync(c => c.ClientId == request.ClientId); - if (client == null || client.ClientSecret != request.ClientSecret) - return Unauthorized("Invalid client credentials."); + if (!string.Equals(authCode.RedirectUri, request.RedirectUri, StringComparison.Ordinal)) + { + return BadRequest(new { error = "invalid_grant", error_description = "redirect_uri mismatch." }); + } - // Decrypt and validate authorization code - var decryptedCode = _encryptionService.Decrypt(request.Code); - var authCode = await _context.AuthorizationCodes - .SingleOrDefaultAsync(c => c.Code == decryptedCode && c.ClientId == request.ClientId); + if (client.RequirePkce || !string.IsNullOrEmpty(authCode.CodeChallenge)) + { + if (!PkceHelper.Validate(request.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod)) + { + return BadRequest(new { error = "invalid_grant", error_description = "Invalid code verifier." }); + } + } - if (authCode == null || authCode.ExpiresAt <= DateTime.UtcNow) - return BadRequest("Invalid or expired authorization code."); + authCode.IsUsed = true; + await _context.SaveChangesAsync(); - if (!ValidatePkce(request.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod)) - return BadRequest("Invalid code verifier."); + ApplicationUser? user = null; + if (!string.IsNullOrEmpty(authCode.UserId)) + { + user = await _context.Users.FindAsync(authCode.UserId); + } - // Generate tokens - // TODO: Look up user and pass it in - var accessToken = _tokenService.GenerateAccessToken(authCode.Subject, authCode.ClientId); - var refreshToken = GenerateRefreshToken(); + var scopes = SplitScopes(authCode.Scopes); + var accessToken = _tokenService.GenerateAccessToken(authCode.Subject, authCode.ClientId, scopes, user); + string? refreshToken = null; - // Encrypt and store refresh token - var encryptedRefreshToken = _encryptionService.Encrypt(refreshToken); - await _tokenService.StoreRefreshToken(authCode.UserId, request.ClientId, encryptedRefreshToken); + if (client.AllowRefreshToken) + { + refreshToken = _tokenService.GenerateRefreshToken(); + await _tokenService.StoreRefreshToken(refreshToken, authCode.UserId, request.ClientId); + } - // Mark authorization code as used _context.AuthorizationCodes.Remove(authCode); await _context.SaveChangesAsync(); - return Ok(new - { - access_token = accessToken, - refresh_token = refreshToken, - token_type = "Bearer", - expires_in = 3600 - }); + return Ok(CreateTokenResponse(accessToken, refreshToken)); } - /// - /// Handle refresh token grant. - /// - /// The request. - /// ]]> private async Task HandleRefreshTokenGrant(TokenRequest request) { - var encryptedToken = request.RefreshToken; + var client = await ValidateClientAsync(request); + if (client is null) + { + return Unauthorized(new { error = "invalid_client" }); + } + + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return BadRequest(new { error = "invalid_request", error_description = "refresh_token is required." }); + } - // Decrypt and validate refresh token - var refreshToken = _encryptionService.Decrypt(encryptedToken); - var storedToken = await _context.RefreshTokens.Include(t => t.User).Include(t => t.Client) - .SingleOrDefaultAsync(t => t.Token == encryptedToken && !t.IsRevoked); + var hashedToken = TokenHelper.HashToken(request.RefreshToken); + var storedToken = await _context.RefreshTokens + .Include(t => t.User) + .SingleOrDefaultAsync(t => t.Token == hashedToken && t.ClientId == request.ClientId && !t.IsRevoked); if (storedToken == null || storedToken.Expiry <= DateTime.UtcNow) - return BadRequest("Invalid or expired refresh token."); - - // Generate new tokens - var accessToken = _tokenService.GenerateAccessToken(storedToken.User.Email ?? storedToken.User.Id, storedToken.ClientId ?? "", null, storedToken.User); - var newRefreshToken = GenerateRefreshToken(); + { + return BadRequest(new { error = "invalid_grant", error_description = "Invalid or expired refresh token." }); + } - // Encrypt and store new refresh token, revoke the old one - var encryptedNewToken = _encryptionService.Encrypt(newRefreshToken); storedToken.IsRevoked = true; - await _tokenService.StoreRefreshToken(encryptedNewToken, storedToken.UserId, request.ClientId); + var scopes = request.Scope is null ? null : SplitScopes(request.Scope); + var subject = storedToken.User?.Id ?? storedToken.UserId ?? storedToken.ClientId ?? request.ClientId; + var accessToken = _tokenService.GenerateAccessToken(subject, storedToken.ClientId ?? request.ClientId, scopes, storedToken.User); + var newRefreshToken = _tokenService.GenerateRefreshToken(); + + await _tokenService.StoreRefreshToken(newRefreshToken, storedToken.UserId, request.ClientId); await _context.SaveChangesAsync(); - return Ok(new - { - access_token = accessToken, - refresh_token = newRefreshToken, - token_type = "Bearer", - expires_in = 3600 - }); + return Ok(CreateTokenResponse(accessToken, newRefreshToken)); } - /// - /// Handle client credentials grant. - /// - /// The request. - /// ]]> private async Task HandleClientCredentialsGrant(TokenRequest request) { - // Validate client credentials - var client = await _context.Clients.SingleOrDefaultAsync(c => c.ClientId == request.ClientId); - if (client == null || client.ClientSecret != request.ClientSecret) + var client = await ValidateClientAsync(request); + if (client is null) { - return Unauthorized("Invalid client credentials."); + return Unauthorized(new { error = "invalid_client" }); } - if (request.Scope != null && !client.AllowedScopes.Contains(request.Scope)) + IEnumerable? scopes = null; + if (!string.IsNullOrWhiteSpace(request.Scope)) { - return BadRequest("Invalid scope."); - } + var requested = SplitScopes(request.Scope); + if (requested.Any(scope => !client.AllowedScopes.Contains(scope))) + { + return BadRequest(new { error = "invalid_scope" }); + } - // Generate access token for the client - var accessToken = _tokenService.GenerateJwtToken(client); + scopes = requested; + } - return Ok(new - { - access_token = accessToken, - token_type = "Bearer", - expires_in = 3600 - }); - } + var accessToken = scopes == null + ? _tokenService.GenerateJwtToken(client) + : _tokenService.GenerateAccessToken(client.ClientId, client.ClientId, scopes); - /// - /// Generate refresh token. - /// - /// A string - private string GenerateRefreshToken() - { - using var rng = RandomNumberGenerator.Create(); - var tokenBytes = new byte[32]; - rng.GetBytes(tokenBytes); - return Convert.ToBase64String(tokenBytes) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); + return Ok(CreateTokenResponse(accessToken)); } - /// - /// Validate pkce. - /// - /// The verifier. - /// The challenge. - /// The method. - /// A bool - private bool ValidatePkce(string verifier, string challenge, string method) + private async Task ValidateClientAsync(TokenRequest request) { - if (method == "S256") + if (string.IsNullOrWhiteSpace(request.ClientId)) { - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); - var hashBase64 = Convert.ToBase64String(hash) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - return hashBase64 == challenge; - } - return false; - } - - - //public async Task ExchangeAuthorizationCode(string code, string clientId, string? codeVerifier) - //{ - // var encryptedCode = _encryptionService.Encrypt(code); - // var authCode = await _context.AuthorizationCodes - // .FirstOrDefaultAsync(c => c.Code == encryptedCode && c.ClientId == clientId); - - // if (authCode == null || authCode.Expiration < DateTime.UtcNow) - // return null; // Invalid or expired authorization code - - // // Validate PKCE (if required) - // if (!string.IsNullOrEmpty(authCode.CodeChallenge)) - // { - // if (string.IsNullOrEmpty(codeVerifier)) - // return null; // PKCE is required, but no verifier was provided - - // var expectedChallenge = PkceHelper.ComputeCodeChallenge(codeVerifier, authCode.CodeChallengeMethod); - - // if (expectedChallenge != authCode.CodeChallenge) - // return null; // PKCE validation failed - // } - - // // Remove used authorization code (one-time use) - // _context.AuthorizationCodes.Remove(authCode); - // await _context.SaveChangesAsync(); - - // // Generate and return access token - // return _tokenService.GenerateAccessToken(authCode.UserId, clientId); - //} - - /// - /// Exchanges authorization code. - /// - /// The authorization code. - /// The client id. - /// The code verifier. - /// - /// ]]> - public async Task ExchangeAuthorizationCode(string authorizationCode, string clientId, string codeVerifier) - { - // Retrieve the authorization code from the database - var authCodeEntity = await _context.AuthorizationCodes - .FirstOrDefaultAsync(ac => ac.Code == authorizationCode && ac.ClientId == clientId); - - if (authCodeEntity == null || authCodeEntity.IsUsed || authCodeEntity.ExpiresAt < DateTime.UtcNow) - { - throw new InvalidOperationException("Invalid or expired authorization code."); + return null; } - // Verify PKCE (Proof Key for Code Exchange) if a code challenge was used - if (!string.IsNullOrEmpty(authCodeEntity.CodeChallenge)) + var client = await _context.Clients.SingleOrDefaultAsync(c => c.ClientId == request.ClientId); + if (client == null || !TokenHelper.SecretsEqual(client.ClientSecret, request.ClientSecret)) { - string computedChallenge = PkceHelper.ComputeCodeChallenge(codeVerifier, authCodeEntity.CodeChallengeMethod); - if (authCodeEntity.CodeChallenge != computedChallenge) - { - throw new InvalidOperationException("Invalid code verifier."); - } + return null; } - // Mark the authorization code as used (to prevent reuse) - authCodeEntity.IsUsed = true; - await _context.SaveChangesAsync(); - - // Generate access and refresh tokens - string accessToken = _tokenService.GenerateAccessToken(authCodeEntity.Subject, clientId); - string refreshToken = _tokenService.GenerateRefreshToken(); - - // Store the refresh token in the database - await _tokenService.StoreRefreshToken(refreshToken, authCodeEntity.UserId, clientId); + return client; + } + private TokenResponse CreateTokenResponse(string accessToken, string? refreshToken = null) + { return new TokenResponse { AccessToken = accessToken, RefreshToken = refreshToken, - ExpiresIn = _tokenService.GetAccessTokenExpiry(), - TokenType = "Bearer" + TokenType = "Bearer", + ExpiresIn = _tokenService.GetAccessTokenExpirySeconds() }; } + private static List SplitScopes(string? scope) + { + if (string.IsNullOrWhiteSpace(scope)) + { + return []; + } + + return [.. scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + } } diff --git a/Authorization.API/Dockerfile b/Authorization.API/Dockerfile index 676a50e..4bd12e0 100644 --- a/Authorization.API/Dockerfile +++ b/Authorization.API/Dockerfile @@ -1,30 +1,26 @@ # See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -# This stage is used when running from VS in fast mode (Default for Debug configuration) FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 - -# This stage is used to build the service project FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Authorization.API/Authorization.API.csproj", "Authorization.API/"] +COPY ["Authorization.API.ServiceDefaults/Authorization.API.ServiceDefaults.csproj", "Authorization.API.ServiceDefaults/"] RUN dotnet restore "./Authorization.API/Authorization.API.csproj" COPY . . WORKDIR "/src/Authorization.API" RUN dotnet build "./Authorization.API.csproj" -c $BUILD_CONFIGURATION -o /app/build -# This stage is used to publish the service project to be copied to the final stage FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./Authorization.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false -# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "Authorization.API.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "Authorization.API.dll"] diff --git a/Authorization.API/Helpers/PkceHelper.cs b/Authorization.API/Helpers/PkceHelper.cs index dbe53fa..56d2624 100644 --- a/Authorization.API/Helpers/PkceHelper.cs +++ b/Authorization.API/Helpers/PkceHelper.cs @@ -5,18 +5,36 @@ namespace Authorization.API.Helpers; public static class PkceHelper { + public const string S256 = "S256"; + public static string ComputeCodeChallenge(string codeVerifier, string? method) { - if (method == "S256") + if (!string.Equals(method, S256, StringComparison.Ordinal)) { - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - return Convert.ToBase64String(hash) - .Replace("+", "-") - .Replace("/", "_") - .TrimEnd('='); + throw new ArgumentException("Only the S256 PKCE method is supported.", nameof(method)); } - return codeVerifier; // "plain" method (not recommended) + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)); + return TokenHelper.ToUrlSafeBase64(hash); } -} + public static bool Validate(string? verifier, string? challenge, string? method) + { + if (string.IsNullOrEmpty(challenge)) + { + return string.IsNullOrEmpty(verifier); + } + + if (string.IsNullOrEmpty(verifier) || !string.Equals(method, S256, StringComparison.Ordinal)) + { + return false; + } + + var expected = ComputeCodeChallenge(verifier, method); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + var actualBytes = Encoding.UTF8.GetBytes(challenge); + + return expectedBytes.Length == actualBytes.Length + && CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } +} diff --git a/Authorization.API/Helpers/TokenHelper.cs b/Authorization.API/Helpers/TokenHelper.cs index 93a8415..cdb55c7 100644 --- a/Authorization.API/Helpers/TokenHelper.cs +++ b/Authorization.API/Helpers/TokenHelper.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text; namespace Authorization.API.Helpers; @@ -7,12 +8,35 @@ public static class TokenHelper public static string GenerateSecureCode(int length = 32) { var bytes = new byte[length]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(bytes); + RandomNumberGenerator.Fill(bytes); + return ToUrlSafeBase64(bytes); + } + + public static string HashToken(string token) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hash); + } + + public static string ToUrlSafeBase64(byte[] bytes) + { return Convert.ToBase64String(bytes) - .Replace("+", "-") // URL-safe - .Replace("/", "_") + .Replace("+", "-", StringComparison.Ordinal) + .Replace("/", "_", StringComparison.Ordinal) .TrimEnd('='); } -} + public static bool SecretsEqual(string? stored, string? provided) + { + var left = Encoding.UTF8.GetBytes(stored ?? string.Empty); + var right = Encoding.UTF8.GetBytes(provided ?? string.Empty); + + if (left.Length != right.Length) + { + CryptographicOperations.FixedTimeEquals(left, left); + return false; + } + + return CryptographicOperations.FixedTimeEquals(left, right); + } +} diff --git a/Authorization.API/HostedService/ExpiredCodeCleanupService.cs b/Authorization.API/HostedService/ExpiredCodeCleanupService.cs index dcb8677..755ee59 100644 --- a/Authorization.API/HostedService/ExpiredCodeCleanupService.cs +++ b/Authorization.API/HostedService/ExpiredCodeCleanupService.cs @@ -1,41 +1,62 @@ using Authorization.API.Context; +using Microsoft.EntityFrameworkCore; namespace Authorization.API.HostedService; -public class ExpiredCodeCleanupService : IHostedService + +public class ExpiredCodeCleanupService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; - private Timer _timer; + private readonly ILogger _logger; - public ExpiredCodeCleanupService(IServiceScopeFactory scopeFactory) + public ExpiredCodeCleanupService(IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; + _logger = logger; } - public Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _timer = new Timer(CleanupExpiredCodes, null, TimeSpan.Zero, TimeSpan.FromMinutes(30)); - return Task.CompletedTask; + try + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30)); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await CleanupAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to clean expired authorization data."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Host is shutting down. + } } - private async void CleanupExpiredCodes(object state) + private async Task CleanupAsync(CancellationToken cancellationToken) { - using var scope = _scopeFactory.CreateScope(); + await using var scope = _scopeFactory.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); - var expiredCodes = context.AuthorizationCodes - .Where(c => c.ExpiresAt <= DateTime.UtcNow); + var expiredCodes = await context.AuthorizationCodes + .Where(c => c.ExpiresAt <= DateTime.UtcNow || c.IsUsed) + .ExecuteDeleteAsync(cancellationToken); - if (expiredCodes.Any()) + var expiredTokens = await context.RefreshTokens + .Where(t => t.Expiry <= DateTime.UtcNow) + .ExecuteDeleteAsync(cancellationToken); + + if (expiredCodes > 0 || expiredTokens > 0) { - context.AuthorizationCodes.RemoveRange(expiredCodes); - await context.SaveChangesAsync(); + _logger.LogInformation( + "Removed {ExpiredCodes} authorization codes and {ExpiredTokens} refresh tokens.", + expiredCodes, + expiredTokens); } } - - public Task StopAsync(CancellationToken cancellationToken) - { - _timer?.Change(Timeout.Infinite, 0); - return Task.CompletedTask; - } } - diff --git a/Authorization.API/Models/ApiResource.cs b/Authorization.API/Models/ApiResource.cs index d1b2c73..fdc6366 100644 --- a/Authorization.API/Models/ApiResource.cs +++ b/Authorization.API/Models/ApiResource.cs @@ -1,7 +1,8 @@ namespace Authorization.API.Models; + public class ApiResource { public int Id { get; set; } - public string Name { get; set; } - public ICollection Scopes { get; set; } + public string Name { get; set; } = string.Empty; + public ICollection Scopes { get; set; } = new List(); } diff --git a/Authorization.API/Models/ApiScope.cs b/Authorization.API/Models/ApiScope.cs index aa67642..3958d0a 100644 --- a/Authorization.API/Models/ApiScope.cs +++ b/Authorization.API/Models/ApiScope.cs @@ -3,6 +3,6 @@ public class ApiScope { public int Id { get; set; } - public string Name { get; set; } // e.g., "read", "write", "profile" - public string Description { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } } diff --git a/Authorization.API/Models/ApplicationUser.cs b/Authorization.API/Models/ApplicationUser.cs index d24b516..38881d5 100644 --- a/Authorization.API/Models/ApplicationUser.cs +++ b/Authorization.API/Models/ApplicationUser.cs @@ -1,11 +1,8 @@ - -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; namespace Authorization.API.Models; + public class ApplicationUser : IdentityUser { public string? FullName { get; set; } - - public ICollection> Claims { get; set; } - } diff --git a/Authorization.API/Models/AuthorizationCode.cs b/Authorization.API/Models/AuthorizationCode.cs index 3f54b96..db9d9cf 100644 --- a/Authorization.API/Models/AuthorizationCode.cs +++ b/Authorization.API/Models/AuthorizationCode.cs @@ -5,27 +5,26 @@ namespace Authorization.API.Models; public class AuthorizationCode { [Key] - public string Code { get; set; } // The actual authorization code (should be securely generated) + public string Code { get; set; } = string.Empty; [Required] - public string ClientId { get; set; } // The client that requested authorization + public string ClientId { get; set; } = string.Empty; - public string? UserId { get; set; } // The user who authorized the request + public string? UserId { get; set; } - public string Subject { get; set; } // The identifier of the user. Allows for anonymous users. + public string Subject { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Timestamp when issued + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime ExpiresAt { get; set; } // Expiration time of the authorization code + public DateTime ExpiresAt { get; set; } - public bool IsUsed { get; set; } = false; // Ensures the code is only used once + public bool IsUsed { get; set; } - public string RedirectUri { get; set; } // The redirect URI used during authorization + public string? RedirectUri { get; set; } - public string CodeChallenge { get; set; } // PKCE Challenge (if applicable) + public string? CodeChallenge { get; set; } - public string CodeChallengeMethod { get; set; } // PKCE Method (e.g., S256) + public string? CodeChallengeMethod { get; set; } - public string Scopes { get; set; } // Space-separated list of scopes granted + public string? Scopes { get; set; } } - diff --git a/Authorization.API/Models/AuthorizationRequest.cs b/Authorization.API/Models/AuthorizationRequest.cs deleted file mode 100644 index 5358ed2..0000000 --- a/Authorization.API/Models/AuthorizationRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Authorization.API.Models; - -public class AuthorizationRequest -{ - public string ClientId { get; set; } - public string RedirectUri { get; set; } - public string CodeChallenge { get; set; } -} diff --git a/Authorization.API/Models/Client.cs b/Authorization.API/Models/Client.cs index 8fe1a06..b29ef6c 100644 --- a/Authorization.API/Models/Client.cs +++ b/Authorization.API/Models/Client.cs @@ -1,55 +1,27 @@ using System.ComponentModel.DataAnnotations; namespace Authorization.API.Models; -/// -/// The client. -/// + public class Client { - /// - /// Gets or sets the id. - /// - /// An int [Key] public int Id { get; set; } - /// - /// Gets or sets the client id. - /// - /// A string - public string ClientId { get; set; } - /// - /// Gets or sets the description. - /// - /// A string - public string Description { get; set; } - /// - /// Gets or sets the client secret. - /// - /// A string - public string ClientSecret { get; set; } - /// - /// Gets or sets the redirect uri. - /// - /// A string + + [Required] + public string ClientId { get; set; } = string.Empty; + + public string? Description { get; set; } + + [Required] + public string ClientSecret { get; set; } = string.Empty; + public string? RedirectUri { get; set; } - /// - /// Gets or sets the post logout redirect uri. - /// - /// A string + public string? PostLogoutRedirectUri { get; set; } - /// - /// Gets or sets a value indicating whether to require pkce. - /// - /// A bool - public bool RequirePkce { get; set; } - /// - /// Gets or sets a value indicating whether allow refresh token. - /// - /// A bool - public bool AllowRefreshToken { get; set; } - /// - /// Gets or sets the allowed scopes. - /// - /// A collection of strings. - public ICollection AllowedScopes { get; set; } + + public bool RequirePkce { get; set; } = true; + + public bool AllowRefreshToken { get; set; } = true; + + public ICollection AllowedScopes { get; set; } = new List(); } diff --git a/Authorization.API/Models/LoginModel.cs b/Authorization.API/Models/LoginModel.cs index 578b52a..9123652 100644 --- a/Authorization.API/Models/LoginModel.cs +++ b/Authorization.API/Models/LoginModel.cs @@ -5,10 +5,13 @@ namespace Authorization.API.Models; public class LoginModel { [Required] - public string Email { get; set; } + [EmailAddress] + public string Email { get; set; } = string.Empty; [Required] - public string Password { get; set; } + public string Password { get; set; } = string.Empty; public bool RememberMe { get; set; } + + public string? ReturnUrl { get; set; } } diff --git a/Authorization.API/Models/RefreshToken.cs b/Authorization.API/Models/RefreshToken.cs index 6a9f5fb..2fd9046 100644 --- a/Authorization.API/Models/RefreshToken.cs +++ b/Authorization.API/Models/RefreshToken.cs @@ -6,12 +6,21 @@ public class RefreshToken { [Key] public int Id { get; set; } - public string Token { get; set; } // Store the encrypted refresh token + + [Required] + public string Token { get; set; } = string.Empty; + public string? UserId { get; set; } + public string? ClientId { get; set; } + public DateTime Expiry { get; set; } + public bool IsRevoked { get; set; } - public DateTime Created { get; set; } // Creation timestamp - public ApplicationUser User { get; set; } // Navigation property to user - public Client Client { get; set; } // For client tokens + + public DateTime Created { get; set; } = DateTime.UtcNow; + + public ApplicationUser? User { get; set; } + + public Client? Client { get; set; } } diff --git a/Authorization.API/Models/RefreshTokenRequest.cs b/Authorization.API/Models/RefreshTokenRequest.cs index 22cb261..2ea408f 100644 --- a/Authorization.API/Models/RefreshTokenRequest.cs +++ b/Authorization.API/Models/RefreshTokenRequest.cs @@ -4,5 +4,5 @@ public class RefreshTokenRequest { public string? UserId { get; set; } public string? ClientId { get; set; } - public string RefreshToken { get; set; } + public string RefreshToken { get; set; } = string.Empty; } diff --git a/Authorization.API/Models/RegisterModel.cs b/Authorization.API/Models/RegisterModel.cs index fef43b4..4dedc23 100644 --- a/Authorization.API/Models/RegisterModel.cs +++ b/Authorization.API/Models/RegisterModel.cs @@ -5,13 +5,13 @@ namespace Authorization.API.Models; public class RegisterModel { [Required] - public string Username { get; set; } + public string Username { get; set; } = string.Empty; [Required] [EmailAddress] - public string Email { get; set; } + public string Email { get; set; } = string.Empty; [Required] [MinLength(6)] - public string Password { get; set; } + public string Password { get; set; } = string.Empty; } diff --git a/Authorization.API/Models/TokenRequest.cs b/Authorization.API/Models/TokenRequest.cs index 863ce76..67b413a 100644 --- a/Authorization.API/Models/TokenRequest.cs +++ b/Authorization.API/Models/TokenRequest.cs @@ -1,13 +1,31 @@ -namespace Authorization.API.Models; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace Authorization.API.Models; public class TokenRequest { - public string GrantType { get; set; } - public string ClientId { get; set; } - public string ClientSecret { get; set; } - public string Code { get; set; } - public string CodeVerifier { get; set; } - public string RedirectUri { get; set; } - public string RefreshToken { get; set; } - public string Scope { get; set; } + [FromForm(Name = "grant_type")] + public string GrantType { get; set; } = string.Empty; + + [FromForm(Name = "client_id")] + public string ClientId { get; set; } = string.Empty; + + [FromForm(Name = "client_secret")] + public string? ClientSecret { get; set; } + + [FromForm(Name = "code")] + public string? Code { get; set; } + + [FromForm(Name = "code_verifier")] + public string? CodeVerifier { get; set; } + + [FromForm(Name = "redirect_uri")] + public string? RedirectUri { get; set; } + + [FromForm(Name = "refresh_token")] + public string? RefreshToken { get; set; } + + [FromForm(Name = "scope")] + public string? Scope { get; set; } } diff --git a/Authorization.API/Models/TokenResponse.cs b/Authorization.API/Models/TokenResponse.cs index d090afb..f157708 100644 --- a/Authorization.API/Models/TokenResponse.cs +++ b/Authorization.API/Models/TokenResponse.cs @@ -1,9 +1,19 @@ -namespace Authorization.API.Models; +using System.Text.Json.Serialization; + +namespace Authorization.API.Models; public class TokenResponse { - public string AccessToken { get; set; } - public string RefreshToken { get; set; } + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } - public string TokenType { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } = "Bearer"; } diff --git a/Authorization.API/Program.cs b/Authorization.API/Program.cs index 065d14b..81775ac 100644 --- a/Authorization.API/Program.cs +++ b/Authorization.API/Program.cs @@ -9,59 +9,30 @@ using Scalar.AspNetCore; using System.Text; -//var builder = WebApplication.CreateBuilder(args); - -//builder.AddServiceDefaults(); - -//builder.Services.AddDbContext(options => -// options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); - -//builder.Services.AddIdentity() -// .AddEntityFrameworkStores() -// .AddDefaultTokenProviders(); - -//builder.Services.AddControllers(); -//// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -//builder.Services.AddOpenApi(); - -//var app = builder.Build(); - -//app.MapDefaultEndpoints(); - -//// Configure the HTTP request pipeline. -//if (app.Environment.IsDevelopment()) -//{ -// app.MapOpenApi(); -//} - -//app.UseHttpsRedirection(); - -//app.UseAuthorization(); - -//app.MapControllers(); - -//app.Run(); - - var builder = WebApplication.CreateBuilder(args); -// Load encryption key from environment variables or configuration +builder.AddServiceDefaults(); + var encryptionKey = builder.Configuration["EncryptionKey"]; -if (string.IsNullOrEmpty(encryptionKey)) +if (string.IsNullOrWhiteSpace(encryptionKey)) +{ + throw new InvalidOperationException("EncryptionKey is missing. Set it in user secrets or environment variables."); +} + +var jwtSettings = builder.Configuration.GetSection("Jwt"); +var jwtKey = jwtSettings["SecretKey"]; +if (string.IsNullOrWhiteSpace(jwtKey)) { - throw new Exception("Encryption key is missing in the configuration."); + throw new InvalidOperationException("Jwt:SecretKey is missing. Set it in user secrets or environment variables."); } -// Add services to the container -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddControllersWithViews(); builder.Services.AddOpenApi(); -// Configure database (replace with your actual connection string) builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); -builder.Services.AddIdentity() +builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); @@ -72,40 +43,31 @@ options.Lockout.AllowedForNewUsers = true; }); - -// Register Encryption Service -builder.Services.AddSingleton(new EncryptionService(encryptionKey)); -builder.Services.AddScoped(); - -// get Jwt settings from configuration -var jwtSettings = builder.Configuration.GetSection("Jwt"); - -// Configure JWT authentication -var jwtKey = jwtSettings["Secret"]; -if (string.IsNullOrEmpty(jwtKey)) +builder.Services.ConfigureApplicationCookie(options => { - throw new Exception("JWT secret key is missing in the configuration."); -} + options.LoginPath = "/account/login"; + options.LogoutPath = "/account/logout"; + options.ExpireTimeSpan = TimeSpan.FromHours(1); + options.SlidingExpiration = true; +}); -var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); +builder.Services.AddSingleton(_ => new EncryptionService(encryptionKey)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) -.AddCookie(options => -{ - options.LoginPath = "/account/login"; // Redirect to login page - options.LogoutPath = "/account/logout"; - options.ExpireTimeSpan = TimeSpan.FromHours(1); -}) .AddJwtBearer(options => { - options.RequireHttpsMetadata = false; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { - IssuerSigningKey = key, + IssuerSigningKey = signingKey, ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, @@ -116,14 +78,15 @@ }; }); -// Register background service to clean expired authorization codes builder.Services.AddHostedService(); var app = builder.Build(); -// Configure middleware pipeline +app.MapDefaultEndpoints(); + if (app.Environment.IsDevelopment()) { + app.MapOpenApi(); app.MapScalarApiReference(); } diff --git a/Authorization.API/Services/EncryptionService.cs b/Authorization.API/Services/EncryptionService.cs index e4aa1dc..d815166 100644 --- a/Authorization.API/Services/EncryptionService.cs +++ b/Authorization.API/Services/EncryptionService.cs @@ -15,6 +15,10 @@ public class EncryptionService : IEncryptionService public EncryptionService(string base64Key) { _key = Convert.FromBase64String(base64Key); + if (_key.Length is not (16 or 24 or 32)) + { + throw new ArgumentException("Encryption key must be 16, 24, or 32 bytes after base64 decoding.", nameof(base64Key)); + } } public string Encrypt(string plainText) @@ -26,17 +30,14 @@ public string Encrypt(string plainText) using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); using var ms = new MemoryStream(); using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + using (var sw = new StreamWriter(cs)) { - using (var sw = new StreamWriter(cs)) - { - sw.Write(plainText); - } + sw.Write(plainText); } var iv = aes.IV; var encryptedContent = ms.ToArray(); - // Combine IV and encrypted content for storage var result = new byte[iv.Length + encryptedContent.Length]; Buffer.BlockCopy(iv, 0, result, 0, iv.Length); Buffer.BlockCopy(encryptedContent, 0, result, iv.Length, encryptedContent.Length); @@ -51,8 +52,12 @@ public string Decrypt(string encryptedText) using var aes = Aes.Create(); aes.Key = _key; - // Extract IV from the beginning of the cipher var iv = new byte[aes.BlockSize / 8]; + if (fullCipher.Length <= iv.Length) + { + throw new CryptographicException("Cipher text is too short."); + } + var cipher = new byte[fullCipher.Length - iv.Length]; Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length); Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length); @@ -66,4 +71,3 @@ public string Decrypt(string encryptedText) return sr.ReadToEnd(); } } - diff --git a/Authorization.API/Services/TokenService.cs b/Authorization.API/Services/TokenService.cs index b2e335b..f4a5c5e 100644 --- a/Authorization.API/Services/TokenService.cs +++ b/Authorization.API/Services/TokenService.cs @@ -1,166 +1,85 @@ -using Authorization.API.Context; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Authorization.API.Context; using Authorization.API.Helpers; using Authorization.API.Models; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; namespace Authorization.API.Services; -/// -/// The token service interface. -/// public interface ITokenService { - /// - /// Generate authorization code. - /// - /// The client id. - /// The user id. - /// The code challenge. - /// The code challenge method. - /// ]]> - Task GenerateAuthorizationCode(string clientId, string userId, string? codeChallenge, string? codeChallengeMethod); - - /// - /// Generate jwt token. - /// - /// The user. - /// A string + Task GenerateAuthorizationCode( + string clientId, + string userId, + string subject, + string? redirectUri, + string? scopes, + string? codeChallenge, + string? codeChallengeMethod); + + string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null); + string GenerateJwtToken(ApplicationUser user); - /// - /// Generate jwt token. - /// - /// The client. - /// A string string GenerateJwtToken(Client client); - /// - /// Generate jwt token. - /// - /// The client id. - /// The user id. - /// A string string GenerateJwtToken(string clientId, string? userId); - /// - /// Generate jwt token. - /// - /// The claims. - /// The expires at. - /// A string string GenerateJwtToken(List claims, DateTime? expiresAt = null); - /// - /// Generate refresh token. - /// - /// A string string GenerateRefreshToken(); - /// - /// Store refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// A Task + int GetAccessTokenExpirySeconds(); + Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null); - /// - /// Validate refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// ]]> Task ValidateRefreshToken(string refreshToken, string? userId = null, string? clientId = null); - /// - /// Revokes refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// A Task Task RevokeRefreshToken(string refreshToken, string? userId = null, string? clientId = null); - /// - /// Revokes user refresh tokens. - /// - /// The user id. - /// A Task Task RevokeUserRefreshTokens(string userId); - /// - /// Revokes client refresh tokens. - /// - /// The client id. - /// A Task Task RevokeClientRefreshTokens(string clientId); - /// - /// Validate the token. - /// - /// The token. - /// A ClaimsPrincipal? ClaimsPrincipal? ValidateToken(string token); } -/// -/// The token service. -/// public class TokenService : ITokenService { - /// - /// The config. - /// private readonly IConfiguration _config; - /// - /// The db context. - /// private readonly ApplicationDbContext _dbContext; - /// - /// The encryption service. - /// - private readonly IEncryptionService _encryptionService; - - /// - /// Initializes a new instance of the class. - /// - /// The config. - /// The context. - public TokenService(IConfiguration config, ApplicationDbContext context, IEncryptionService encryptionService) + + public TokenService(IConfiguration config, ApplicationDbContext context) { _config = config; _dbContext = context; - _encryptionService = encryptionService; } - /// - /// Generate authorization code. - /// - /// The client id. - /// The user id. - /// The code challenge. - /// The code challenge method. - /// ]]> - public async Task GenerateAuthorizationCode(string clientId, string userId, string? codeChallenge, string? codeChallengeMethod) + public async Task GenerateAuthorizationCode( + string clientId, + string userId, + string subject, + string? redirectUri, + string? scopes, + string? codeChallenge, + string? codeChallengeMethod) { - var code = TokenHelper.GenerateSecureCode(); // Generate a random secure code - var encryptedCode = _encryptionService.Encrypt(code); + var code = TokenHelper.GenerateSecureCode(); var authCode = new AuthorizationCode { - Code = encryptedCode, + Code = TokenHelper.HashToken(code), ClientId = clientId, UserId = userId, + Subject = subject, + RedirectUri = redirectUri, + Scopes = scopes, ExpiresAt = DateTime.UtcNow.AddMinutes(5), - CodeChallenge = codeChallenge, // Store PKCE challenge - CodeChallengeMethod = codeChallengeMethod // Store PKCE method + CodeChallenge = codeChallenge, + CodeChallengeMethod = codeChallengeMethod }; _dbContext.AuthorizationCodes.Add(authCode); @@ -169,228 +88,174 @@ public async Task GenerateAuthorizationCode(string clientId, string user return code; } - public string GenerateAccessToken(string subject, string clientId, List? scopes = null, ApplicationUser? user = null) + public string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null) { var claims = new List { - new Claim(JwtRegisteredClaimNames.Sub, subject), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), - new Claim("client_id", clientId) + new(JwtRegisteredClaimNames.Sub, subject), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), + new("client_id", clientId) }; if (user != null) { - claims.Add(new(ClaimTypes.NameIdentifier, user.Id)); - claims.Add(new(ClaimTypes.Name, user.UserName ?? "")); - claims.Add(new(ClaimTypes.Email, user.Email ?? "")); + claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id)); + claims.Add(new Claim(ClaimTypes.Name, user.UserName ?? string.Empty)); + if (!string.IsNullOrEmpty(user.Email)) + { + claims.Add(new Claim(ClaimTypes.Email, user.Email)); + } } - // Add scopes as claims if provided if (scopes != null) { - claims.Add(new Claim("scope", string.Join(" ", scopes))); + var scopeValue = string.Join(' ', scopes); + if (!string.IsNullOrWhiteSpace(scopeValue)) + { + claims.Add(new Claim("scope", scopeValue)); + } } return GenerateJwtToken(claims); } - public int GetAccessTokenExpiry() + public int GetAccessTokenExpirySeconds() { var expiry = _config["Jwt:AccessTokenExpiryMinutes"]; - - if (int.TryParse(expiry, out var result)) + if (int.TryParse(expiry, out var minutes) && minutes > 0) { - return result * 60; // Convert minutes to seconds + return minutes * 60; } - return 2400; // Convert minutes to seconds + return 30 * 60; } - - /// - /// Generate jwt token. - /// - /// The user. - /// A string public string GenerateJwtToken(ApplicationUser user) { var claims = new List { - new (ClaimTypes.NameIdentifier, user.Id), - new (ClaimTypes.Name, user.UserName ?? ""), - new (ClaimTypes.Email, user.Email ?? "") + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Name, user.UserName ?? string.Empty), + new(JwtRegisteredClaimNames.Sub, user.Id) }; + if (!string.IsNullOrEmpty(user.Email)) + { + claims.Add(new Claim(ClaimTypes.Email, user.Email)); + } + return GenerateJwtToken(claims); } - /// - /// Generate jwt token. - /// - /// The client. - /// A string public string GenerateJwtToken(Client client) { var claims = new List { - new (ClaimTypes.NameIdentifier, client.ClientId), - new (ClaimTypes.Name, client.ClientId ?? ""), - new ("client_id", client.ClientId ?? "") + new(ClaimTypes.NameIdentifier, client.ClientId), + new(ClaimTypes.Name, client.ClientId), + new(JwtRegisteredClaimNames.Sub, client.ClientId), + new("client_id", client.ClientId) }; + if (client.AllowedScopes.Count > 0) + { + claims.Add(new Claim("scope", string.Join(' ', client.AllowedScopes))); + } + return GenerateJwtToken(claims); } - /// - /// Generates a JWT access token for the given user or client. - /// public string GenerateJwtToken(string clientId, string? userId) { var claims = new List { - new (JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new ("client_id", clientId) + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new("client_id", clientId) }; if (!string.IsNullOrEmpty(userId)) { claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + claims.Add(new Claim(JwtRegisteredClaimNames.Sub, userId)); } return GenerateJwtToken(claims); } - /// - /// Generate jwt token. - /// - /// The claims. - /// The expires at. - /// A string public string GenerateJwtToken(List claims, DateTime? expiresAt = null) { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"])); + var secret = _config["Jwt:SecretKey"] + ?? throw new InvalidOperationException("Jwt:SecretKey is missing."); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, - expires: expiresAt ?? DateTime.UtcNow.AddMinutes(60), + expires: expiresAt ?? DateTime.UtcNow.AddSeconds(GetAccessTokenExpirySeconds()), signingCredentials: credentials ); + return new JwtSecurityTokenHandler().WriteToken(token); } - // Generate a new refresh token (this is the token you'll issue) - /// - /// Generate refresh token. - /// - /// A string public string GenerateRefreshToken() { - var randomNumber = new byte[32]; - using var rng = RandomNumberGenerator.Create(); - - rng.GetBytes(randomNumber); - return Convert.ToBase64String(randomNumber); + return TokenHelper.GenerateSecureCode(64); } - // Store the refresh token in the database - /// - /// Store refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// - /// A Task public async Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null) { - // Ensure that only one of userId or clientId is set - if (userId == null && clientId == null) - { - throw new ArgumentException("Either userId or clientId must be provided"); - } - - if (userId != null && clientId != null) + if (string.IsNullOrWhiteSpace(userId) && string.IsNullOrWhiteSpace(clientId)) { - throw new ArgumentException("Only one of userId or clientId should be provided"); + throw new ArgumentException("Either userId or clientId must be provided."); } var refreshTokenEntity = new RefreshToken { - Token = refreshToken, - Expiry = DateTime.UtcNow.AddDays(30), // Refresh token expiration (adjust as necessary) + Token = TokenHelper.HashToken(refreshToken), + Expiry = DateTime.UtcNow.AddDays(30), IsRevoked = false, + Created = DateTime.UtcNow, + UserId = userId, + ClientId = clientId }; - // Store the refresh token for a user - if (userId != null) - { - refreshTokenEntity.UserId = userId; - } - - // Store the refresh token for a client - if (clientId != null) - { - refreshTokenEntity.ClientId = clientId; - } - - // Add to database await _dbContext.RefreshTokens.AddAsync(refreshTokenEntity); await _dbContext.SaveChangesAsync(); } - /// - /// Revokes refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// - /// - /// A Task public async Task RevokeRefreshToken(string refreshToken, string? userId = null, string? clientId = null) { - // Ensure that only one of userId or clientId is provided - if (userId == null && clientId == null) + var hashed = TokenHelper.HashToken(refreshToken); + var query = _dbContext.RefreshTokens.Where(rt => rt.Token == hashed && !rt.IsRevoked); + + if (!string.IsNullOrWhiteSpace(userId)) { - throw new ArgumentException("Either userId or clientId must be provided"); + query = query.Where(rt => rt.UserId == userId); } - if (userId != null && clientId != null) + if (!string.IsNullOrWhiteSpace(clientId)) { - throw new ArgumentException("Only one of userId or clientId should be provided"); + query = query.Where(rt => rt.ClientId == clientId); } - // Find the refresh token in the database - var refreshTokenQuery = _dbContext.RefreshTokens.Where(rt => rt.Token == refreshToken); - - var refreshTokenEntity = await (userId == null - ? refreshTokenQuery.FirstOrDefaultAsync(rt => rt.ClientId == clientId) - : refreshTokenQuery.FirstOrDefaultAsync(rt => rt.UserId == userId)); - + var refreshTokenEntity = await query.FirstOrDefaultAsync(); if (refreshTokenEntity == null) { - throw new InvalidOperationException("Refresh token not found or does not belong to the specified user or client"); + throw new InvalidOperationException("Refresh token not found or does not belong to the specified user or client."); } - // Mark the token as revoked refreshTokenEntity.IsRevoked = true; - - // Save changes to the database await _dbContext.SaveChangesAsync(); } - /// - /// Revokes user refresh tokens. - /// - /// The user id. - /// A Task public async Task RevokeUserRefreshTokens(string userId) { var refreshTokens = await _dbContext.RefreshTokens - .Where(rt => rt.UserId == userId) + .Where(rt => rt.UserId == userId && !rt.IsRevoked) .ToListAsync(); foreach (var refreshToken in refreshTokens) @@ -401,15 +266,10 @@ public async Task RevokeUserRefreshTokens(string userId) await _dbContext.SaveChangesAsync(); } - /// - /// Revokes client refresh tokens. - /// - /// The client id. - /// A Task public async Task RevokeClientRefreshTokens(string clientId) { var refreshTokens = await _dbContext.RefreshTokens - .Where(rt => rt.ClientId == clientId) + .Where(rt => rt.ClientId == clientId && !rt.IsRevoked) .ToListAsync(); foreach (var refreshToken in refreshTokens) @@ -420,44 +280,38 @@ public async Task RevokeClientRefreshTokens(string clientId) await _dbContext.SaveChangesAsync(); } - // Validate if the refresh token is still valid - /// - /// Validate refresh token. - /// - /// The refresh token. - /// The user id. - /// The client id. - /// ]]> public async Task ValidateRefreshToken(string refreshToken, string? userId = null, string? clientId = null) { + var hashed = TokenHelper.HashToken(refreshToken); var refreshTokenEntity = await _dbContext.RefreshTokens - .FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked && rt.Expiry > DateTime.UtcNow); + .FirstOrDefaultAsync(rt => rt.Token == hashed && !rt.IsRevoked && rt.Expiry > DateTime.UtcNow); - // Validate User-based refresh token - if (userId != null && refreshTokenEntity != null && refreshTokenEntity.UserId == userId) + if (refreshTokenEntity == null) { - return refreshTokenEntity.Expiry > DateTime.UtcNow; + return false; } - // Validate Client-based refresh token - if (clientId != null && refreshTokenEntity != null && refreshTokenEntity.ClientId == clientId) + if (!string.IsNullOrWhiteSpace(userId) && refreshTokenEntity.UserId != userId) { - return refreshTokenEntity.Expiry > DateTime.UtcNow; + return false; } - return false; - } + if (!string.IsNullOrWhiteSpace(clientId) && refreshTokenEntity.ClientId != clientId) + { + return false; + } + return true; + } - /// - /// Validates a JWT token and returns the claims principal. - /// public ClaimsPrincipal? ValidateToken(string token) { try { var tokenHandler = new JwtSecurityTokenHandler(); - var key = Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"]); + var secret = _config["Jwt:SecretKey"] + ?? throw new InvalidOperationException("Jwt:SecretKey is missing."); + var key = Encoding.UTF8.GetBytes(secret); var validationParameters = new TokenValidationParameters { @@ -467,15 +321,15 @@ public async Task ValidateRefreshToken(string refreshToken, string? userId ValidateIssuerSigningKey = true, ValidIssuer = _config["Jwt:Issuer"], ValidAudience = _config["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey(key) + IssuerSigningKey = new SymmetricSecurityKey(key), + ClockSkew = TimeSpan.Zero }; - var principal = tokenHandler.ValidateToken(token, validationParameters, out _); - return principal; + return tokenHandler.ValidateToken(token, validationParameters, out _); } catch { - return null; // Token is invalid or expired + return null; } } } diff --git a/Authorization.API/Views/Account/Login.cshtml b/Authorization.API/Views/Account/Login.cshtml new file mode 100644 index 0000000..f102ec5 --- /dev/null +++ b/Authorization.API/Views/Account/Login.cshtml @@ -0,0 +1,98 @@ +@model Authorization.API.Models.LoginModel +@{ + Layout = null; + ViewData["Title"] = "Sign in"; +} + + + + + + Sign in + + + +
+

Sign in

+

Use your account to continue the authorization request.

+
+
+ + + + + + + + + +
+
+ + diff --git a/Authorization.API/Views/_ViewImports.cshtml b/Authorization.API/Views/_ViewImports.cshtml new file mode 100644 index 0000000..d943b39 --- /dev/null +++ b/Authorization.API/Views/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using Authorization.API.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/Authorization.API/appsettings.Development.json b/Authorization.API/appsettings.Development.json index 0c208ae..bd5d487 100644 --- a/Authorization.API/appsettings.Development.json +++ b/Authorization.API/appsettings.Development.json @@ -4,5 +4,12 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - } + }, + "Jwt": { + "SecretKey": "DEV_ONLY_JWT_SIGNING_KEY_MUST_BE_LONG_ENOUGH_32", + "Issuer": "https://localhost:7074", + "Audience": "https://localhost:7074", + "AccessTokenExpiryMinutes": 30 + }, + "EncryptionKey": "TG9jYWxEZXYtQUVTMjU2LUVuY3J5cHRpb24tS2V5cyE=" } diff --git a/Authorization.API/appsettings.json b/Authorization.API/appsettings.json index d15f4e6..7bbbf21 100644 --- a/Authorization.API/appsettings.json +++ b/Authorization.API/appsettings.json @@ -7,13 +7,11 @@ }, "AllowedHosts": "*", "Jwt": { - "SecretKey": "YourSuperSecretKeyForJwtSigning123!", "Issuer": "https://yourdomain.com", "Audience": "https://yourapi.com", "AccessTokenExpiryMinutes": 30 }, - "EncryptionKey": "YourBase64EncodedEncryptionKey", "ConnectionStrings": { - "DefaultConnection": "Server=your-server;Database=your-db;User Id=your-user;Password=your-password;" + "DefaultConnection": "Server=localhost;Database=AuthorizationApi;Trusted_Connection=True;TrustServerCertificate=True;" } }