Authentication¶
- Authentication is the process of validating the identity of a registered user who is accessing a service or an application.
- Authorization is the process of validating the authenticated user if the user has permissions to access a certain service or an application.
Cookie-based Auth¶
- In a typical cookie-based authentication flow, a browser communicates directly with a server.
- The client sends authentication credentials (e.g. username and password) to the server, usually via an endpoint such as /authenticate.
- The server validates the credentials. If they are valid, it creates a server-side session, typically stored in memory or a session store.
- The server returns a sessionId to the client. This identifier is stored in a browser cookie and is automatically included in subsequent requests.
- For every follow-up request, the browser sends the cookie containing the sessionId. The server uses this value to locate the corresponding session and authenticate the user.
- This approach is called cookie-based authentication because authentication relies on a session identifier stored in a cookie.
- Limitations:
- Scalability: Each authenticated user requires a server-side session. At large scale (e.g. millions of concurrent users), maintaining and synchronizing these sessions can become expensive and impact performance.
- Security Risk: If authentication cookies are compromised (e.g. via XSS or insecure transport), the attacker gains access to the sessionId and can impersonate the user until the session expires or is revoked.
Token-based Auth¶
-
In token-based authentication, the client and server interact without creating server-side sessions.
- The client sends authentication credentials (such as username and password) to the server.
- The server validates the credentials. If they are valid, the server does not create a session. Instead, it generates a token.
- The token is a cryptographically signed (and sometimes encrypted) string that contains enough information for the server to identify the user and validate the request.
- The token is returned to the client and stored in the browser (for example, in memory or secure storage).
- For subsequent requests, the client sends the token in the Authorization header using the Bearer scheme:
- The server validates the token on each request and uses the information inside it to authenticate the user.
-
Token Lifetime
- Tokens have a limited expiration time.
- Access tokens are typically short-lived (often 5–15 minutes).
- If an access token is compromised, its impact is limited by its short lifetime.
-
Access Tokens vs Refresh Token
- Access Token:
- Short-lived (5–15 minutes)
- Sent with every request
- Used to access protected APIs
- Refresh Token:
- Long-lived (days or weeks)
- Stored securely (database)
- Used only to get a new access token
- Access Token:
Json Web Token¶
- The JWT is an open standard that defines a compact and self-contained way for securely transmitting information between parties as a JSON object.
JWT is signed, not encrypted, unless JWE (JSON Web Encryption) is explicitly used.
- It has three parts:
- Header: Type of token, signing algorithm
- Payload: Claims(Registered, Public, Private)
- Signature: Signature(Secret)
Setup JWT & EF¶
This section describes how to configure ASP.NET Core Identity, Entity Framework Core, and JWT-based authentication using access and refresh tokens.
-
Step 1: Required NuGet Packages
- Install the following packages:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Tools
- Microsoft.AspNetCore.Authentication.JwtBearer
- Install the following packages:
-
Step 2: JWT Configuration
appsettings.json"JwtSettings":{ "SecretKey": "xxxxxxxxxx", "Issuer": "https://localhost:5002/", "Audience": "user", "AccessTokenMinutes" : "10", "RefreshTokenDays" : "180" }⚠️ Store SecretKey securely (User Secrets or environment variables).
-
Step 3: Settings POCO
- Strongly-typed configuration model for JWT settings:
-
Step 4: App Configuration
Program.csvar builder = WebApplication.CreateBuilder(args); var configuration = new ConfigurationBuilder() .SetBasePath(Environment.CurrentDirectory) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) // lowest priority .AddUserSecrets<Program>(optional: true, reloadOnChange: true) // overrides json .AddEnvironmentVariables() // highest priority .Build(); builder.Services.Configure<JwtSettings>(configuration.GetSection("JwtSettings")); var jwt = configuration.GetSection("JwtSettings").Get<JwtSettings>()!; builder.Services.AddDbContext<AppDbContext>(opt => opt.UseSqlServer(configuration.GetConnectionString("DBConnection"))); -
Step 5: Configure Identity
Program.csbuilder.Services .AddIdentity<User, Role>(options => { options.User.RequireUniqueEmail = true; options.Password.RequireDigit = true; options.Password.RequiredLength = 8; options.Password.RequiredUniqueChars = 2; options.Password.RequireLowercase = true; options.Password.RequireNonAlphanumeric = true; options.Password.RequireUppercase = true; options.Lockout.AllowedForNewUsers = true; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; options.SignIn.RequireConfirmedEmail = true; }) .AddEntityFrameworkStores<AppDbContext>() .AddDefaultTokenProviders(); -
Step 6: JWT Token Validation
Program.csvar key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)); var tokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = key, ValidateIssuer = true, ValidIssuer = jwt.Issuer, ValidateAudience = true, ValidAudience = jwt.Audience, ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; builder.Services.AddSingleton(tokenValidationParameters); -
Step 7: Authentication Middleware
Program.csbuilder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.SaveToken = true; options.RequireHttpsMetadata = true; // disable only in local dev options.TokenValidationParameters = tokenValidationParameters; }); builder.Services.AddAuthorization(); -
Step 8: HTTP Pipeline
-
Step 9: Role Seeding
public static class AppDbInitializer { public static async Task SeedRoles(IApplicationBuilder app) { using var scope = app.ApplicationServices.CreateScope(); var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>(); async Task EnsureRole(string roleName) { if (!await roleManager.RoleExistsAsync(roleName)) { await roleManager.CreateAsync(new Role { Name = roleName }); } } await EnsureRole("Admin"); await EnsureRole("User"); } } -
Step 10: Setup DB Context
AppDbContext.cspublic class AppDbContext : IdentityDbContext< User, Role, long, IdentityUserClaim<long>, IdentityUserRole<long>, IdentityUserLogin<long>, IdentityRoleClaim<long>, IdentityUserToken<long>> { public DbSet<UserProfile> UserProfiles => Set<UserProfile>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.HasDefaultSchema("Identity"); // Rename Identity tables (remove AspNet*) builder.Entity<User>().ToTable("User"); builder.Entity<Role>().ToTable("Role"); builder.Entity<IdentityUserRole<long>>().ToTable("UserRole"); builder.Entity<IdentityUserClaim<long>>().ToTable("UserClaim"); builder.Entity<IdentityUserLogin<long>>().ToTable("UserLogin"); builder.Entity<IdentityRoleClaim<long>>().ToTable("RoleClaim"); builder.Entity<IdentityUserToken<long>>().ToTable("UserToken"); // (Optional) tweak PK/indices names if you want consistent naming, EF will generate defaults otherwise // Example: // builder.Entity<User>().HasKey(u => u.Id).HasName("PK_Users"); // Ensure FK columns are bigint where needed—Identity generics <long> already do that. } } -
Step 11: Setup Identity Models
public class User : IdentityUser<long> { public bool IsApproved { get; set; } } public class Role : IdentityRole<long> { public long? ParentRoleId { get; set; } public string? Description { get; set; } public bool IsDeleted { get; set; } public Role? ParentRole { get; set; } public ICollection<IdentityRoleClaim<long>> RoleClaims { get; set; } = new List<IdentityRoleClaim<long>>(); } public class UserProfile { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; set; } public long UserId { get; set; } public string? CompanyName { get; set; } public required string Firstname { get; set; } public required string Lastname { get; set; } public string? PostalCode { get; set; } public string? City { get; set; } public string? Street { get; set; } public string? Description { get; set; } public virtual User User { get; set; } = null!; } -
Step 12: Refresh Token
public class RefreshToken { [Key] public long Id { get; set; } public string Token { get; set; } = null!; public string JwtId { get; set; } = null!; public bool IsRevoked { get; set; } public DateTime DateAdded { get; set; } public DateTime DateExpire { get; set; } public long UserId { get; set; } public User User { get; set; } = null!; }
AuthService¶
-
Step 1: Auth Result Model
-
Step 2: AuthService Interface
-
Step 3: AuthService Implementation
public sealed class AuthService : IAuthService { private readonly AppDbContext _context; private readonly UserManager<User> _userManager; private readonly JwtSettings _jwt; private readonly TokenValidationParameters _tokenValidationParameters; public AuthService( AppDbContext context, UserManager<User> userManager, IOptions<JwtSettings> jwt, TokenValidationParameters tokenValidationParameters) { _context = context; _userManager = userManager; _jwt = jwt.Value; _tokenValidationParameters = tokenValidationParameters; } public async Task<AuthResult> LoginAsync(string email, string password) { var user = await _userManager.FindByEmailAsync(email) ?? throw new UnauthorizedAccessException(); if (!await _userManager.IsEmailConfirmedAsync(user)) throw new UnauthorizedAccessException(); if (!await _userManager.CheckPasswordAsync(user, password)) throw new UnauthorizedAccessException(); return await IssueTokensAsync(user); } public async Task<AuthResult> RefreshAsync(string accessToken, string refreshToken) { var stored = await _context.RefreshTokens .Include(x => x.User) .FirstOrDefaultAsync(x => x.Token == refreshToken); if (stored is null || stored.IsRevoked || stored.DateExpire <= DateTime.UtcNow) throw new UnauthorizedAccessException(); var handler = new JwtSecurityTokenHandler(); SecurityToken validated; var parameters = _tokenValidationParameters.Clone(); parameters.ValidateLifetime = false; try { handler.ValidateToken(accessToken, parameters, out validated); } catch { throw new UnauthorizedAccessException(); } var jwt = (JwtSecurityToken)validated; var jti = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Jti).Value; if (stored.JwtId != jti) throw new UnauthorizedAccessException(); stored.IsRevoked = true; await _context.SaveChangesAsync(); return await IssueTokensAsync(stored.User); } public async Task LogoutAsync(long userId) { var tokens = await _context.RefreshTokens .Where(x => x.UserId == userId && !x.IsRevoked) .ToListAsync(); foreach (var token in tokens) token.IsRevoked = true; await _context.SaveChangesAsync(); } private async Task<AuthResult> IssueTokensAsync(User user) { var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(JwtRegisteredClaimNames.Email, user.Email!), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var roles = await _userManager.GetRolesAsync(user); claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey)); var token = new JwtSecurityToken( issuer: _jwt.Issuer, audience: _jwt.Audience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_jwt.AccessTokenMinutes), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); var refresh = new RefreshToken { UserId = user.Id, JwtId = token.Id, Token = $"{Guid.NewGuid()}-{Guid.NewGuid()}", DateAdded = DateTime.UtcNow, DateExpire = DateTime.UtcNow.AddDays(_jwt.RefreshTokenDays) }; _context.RefreshTokens.Add(refresh); await _context.SaveChangesAsync(); return new AuthResult( new JwtSecurityTokenHandler().WriteToken(token), token.ValidTo ); } }
RegistrationService¶
-
Step 1: RegistrationService Interface
-
Step 2: RegistrationService Implementation
public sealed class RegistrationService : IRegistrationService { private readonly AppDbContext _context; private readonly UserManager<User> _userManager; private readonly IEmailService _emailService; public RegistrationService( AppDbContext context, UserManager<User> userManager, IEmailService emailService) { _context = context; _userManager = userManager; _emailService = emailService; } public async Task RegisterAsync(RegisterModel model) { using var tx = await _context.Database.BeginTransactionAsync(); try { if (await _userManager.FindByEmailAsync(model.Email) != null) throw new InvalidOperationException("User already exists"); var user = new User { Email = model.Email, UserName = model.Email }; var result = await _userManager.CreateAsync(user, model.Password); if (!result.Succeeded) throw new InvalidOperationException("User creation failed"); await _userManager.AddToRoleAsync(user, "User"); _context.UserProfiles.Add(new UserProfile { UserId = user.Id, Firstname = model.Firstname, Lastname = model.Lastname }); await _context.SaveChangesAsync(); await tx.CommitAsync(); await _emailService.SendConfirmationAsync(user); } catch { await tx.RollbackAsync(); throw; } } }
EmailService¶
-
Step 1: EmailService Interface
-
Step 2: EmailService Implementation
public sealed class EmailService : IEmailService { private readonly UserManager<User> _userManager; private readonly IConfiguration _config; private readonly IEmailSender _emailSender; public EmailService( UserManager<User> userManager, IConfiguration config, IEmailSender emailSender) { _userManager = userManager; _config = config; _emailSender = emailSender; } public async Task SendConfirmationAsync(User user) { var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = $"{_config["Production:ProductionUrl"]}/account/confirm-email" + $"?userId={user.Id}&code={Uri.EscapeDataString(code)}"; await _emailSender.SendEmailAsync( user.Email!, "Activate your account", BuildHtml("EmailConfirmationTemplate", callbackUrl)); } private static string BuildHtml(string template, string url) { var html = File.ReadAllText($"Templates/{template}.html"); return html.Replace("{CallbackUrl}", url); } } - Step 3: SMTPEmail Sender
- Step 4: SMTPEmailSender Implementation
public sealed class SmtpEmailSender : IEmailSender { private readonly SmtpSettings _settings; public SmtpEmailSender(IOptions<SmtpSettings> settings) { _settings = settings.Value; } public async Task SendEmailAsync(string email, string subject, string htmlMessage) { using var client = new SmtpClient(_settings.Host, _settings.Port) { Credentials = new NetworkCredential( _settings.Username, _settings.Password), _settings.EnableSsl }; var message = new MailMessage { From = new MailAddress(_settings.From), Subject = subject, Body = htmlMessage, IsBodyHtml = true }; message.To.Add(email); await client.SendMailAsync(message); } }
Controller¶
[ApiController]
[Route("account")]
public sealed class AccountController : ControllerBase
{
private readonly IAuthService _auth;
private readonly IRegistrationService _registration;
private readonly UserManager<User> _userManager;
public AccountController(
IAuthService auth,
IRegistrationService registration,
UserManager<User> userManager)
{
_auth = auth;
_registration = registration;
_userManager = userManager;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterModel model)
{
await _registration.RegisterAsync(model);
return Ok("User created. Please confirm your email.");
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
var result = await _auth.LoginAsync(model.Email, model.Password);
return Ok(result);
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] TokenRequestModel model)
{
var result = await _auth.RefreshAsync(model.Token, model.RefreshToken);
return Ok(result);
}
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
var userId = long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await _auth.LogoutAsync(userId);
return Ok();
}
[HttpGet("confirm-email")]
[AllowAnonymous]
public async Task<IActionResult> ConfirmEmail(
[FromQuery] string userId,
[FromQuery] string code)
{
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(code))
return BadRequest("Invalid request");
var user = await _userManager.FindByIdAsync(userId);
if (user == null) return BadRequest("Invalid user");
var result = await _userManager.ConfirmEmailAsync(user, code);
if (!result.Succeeded)
return BadRequest("Confirmation failed");
return Ok("Email confirmed successfully");
}
}
Dependency Injection¶
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRegistrationService, RegistrationService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
Global exception handling¶
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
context.Response.ContentType = "application/json";
var response = new
{
message = "An error occurred"
};
switch (exception)
{
case UnauthorizedAccessException:
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
response = new { message = "Unauthorized" };
break;
case InvalidOperationException:
context.Response.StatusCode = StatusCodes.Status400BadRequest;
response = new { message = exception.Message };
break;
default:
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
break;
}
await context.Response.WriteAsJsonAsync(response);
});
});