diff --git a/Auth/Controllers/AuthController.cs b/Auth/Controllers/AuthController.cs new file mode 100644 index 0000000..b3fefec --- /dev/null +++ b/Auth/Controllers/AuthController.cs @@ -0,0 +1,113 @@ +using Auth.Models; +using Auth.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Auth.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly UserService _users; + private readonly IConfiguration _cfg; + private readonly BlacklistService _blacklist; + + public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist) + { + _users = users; _cfg = cfg; _blacklist = blacklist; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest req) + { + if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) + return BadRequest("Username and password required"); + + if (await _users.GetByUsernameAsync(req.Username) != null) + return BadRequest("User already exists"); + + var hash = BCrypt.Net.BCrypt.HashPassword(req.Password); + var user = new User { Username = req.Username, PasswordHash = hash, Role = "USER", Email = req.Email }; + await _users.CreateAsync(user); + return Ok("User created"); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest req) + { + var user = await _users.GetByUsernameAsync(req.Username); + if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + return Unauthorized(); + + var (accessToken, jti, expUtc) = GenerateJwtToken(user); + user.RefreshToken = Guid.NewGuid().ToString("N"); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await _users.UpdateAsync(user); + + return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc }); + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest req) + { + var user = await _users.GetByUsernameAsync(req.Username); + if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow) + return Unauthorized("Invalid or expired refresh token"); + + var (accessToken, _, expUtc) = GenerateJwtToken(user); + return Ok(new { accessToken, exp = expUtc }); + } + + [HttpPost("logout")] + [Authorize(Roles = "USER,SUPER")] + public async Task Logout() + { + var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); + if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing"); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo); + return Ok("Logged out."); + } + + [HttpPost("role")] + [Authorize(Roles = "SUPER")] + public async Task ChangeUserRole([FromBody] ChangeRoleRequest req) + { + if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'"); + var user = await _users.GetByUsernameAsync(req.Username); + if (user is null) return NotFound("User not found"); + user.Role = req.NewRole; + await _users.UpdateAsync(user); + return Ok($"{req.Username}'s role updated to {req.NewRole}"); + } + + [HttpGet("users")] + [Authorize(Roles = "SUPER")] + public async Task GetAllUsers() => Ok(await _users.GetAllAsync()); + + private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user) + { + var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!); + var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi"; + var audience = _cfg["Jwt:Audience"] ?? issuer; + + var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); + var jti = Guid.NewGuid().ToString("N"); + var claims = new[] + { + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, jti) + }; + + var exp = DateTime.UtcNow.AddMinutes(15); + var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds); + return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp); + } +} diff --git a/Auth/Models/Dto.cs b/Auth/Models/Dto.cs new file mode 100644 index 0000000..5fc1e3b --- /dev/null +++ b/Auth/Models/Dto.cs @@ -0,0 +1,6 @@ +namespace Auth.Models; + +public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } } +public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } +public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; } +public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; } diff --git a/Auth/Models/User.cs b/Auth/Models/User.cs new file mode 100644 index 0000000..cb767ce --- /dev/null +++ b/Auth/Models/User.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Auth.Models; + +public class User +{ + [BsonId] [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = default!; + public string Username { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public string Role { get; set; } = "USER"; + public string? Email { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiry { get; set; } +} diff --git a/Auth/Program.cs b/Auth/Program.cs index ee9d65d..ffad145 100644 --- a/Auth/Program.cs +++ b/Auth/Program.cs @@ -1,41 +1,53 @@ -var builder = WebApplication.CreateBuilder(args); +using Auth.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// AuthN/JWT +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "GameAuthApi"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = issuer, + ValidateAudience = true, ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + o.Events = new JwtBearerEvents + { + OnTokenValidated = async ctx => + { + var jti = ctx.Principal?.FindFirstValue(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti); + if (!string.IsNullOrEmpty(jti)) + { + var bl = ctx.HttpContext.RequestServices.GetRequiredService(); + if (await bl.IsBlacklistedAsync(jti)) ctx.Fail("Token revoked"); + } + } + }; + }); + +builder.Services.AddAuthorization(); var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/Auth/Services/BlacklistService.cs b/Auth/Services/BlacklistService.cs new file mode 100644 index 0000000..42aaddf --- /dev/null +++ b/Auth/Services/BlacklistService.cs @@ -0,0 +1,36 @@ +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace Auth.Services; + +public class BlacklistedToken +{ + [BsonId] public string Jti { get; set; } = default!; + public DateTime ExpiresAt { get; set; } +} + +public class BlacklistService +{ + private readonly IMongoCollection _col; + + public BlacklistService(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("token_blacklist"); + + // TTL index so revocations expire automatically + var keys = Builders.IndexKeys.Ascending(x => x.ExpiresAt); + _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + } + + public Task AddToBlacklistAsync(string jti, DateTime expiresAt) => + _col.ReplaceOneAsync(x => x.Jti == jti, + new BlacklistedToken { Jti = jti, ExpiresAt = expiresAt }, + new ReplaceOptions { IsUpsert = true }); + + public Task IsBlacklistedAsync(string jti) => + _col.Find(x => x.Jti == jti).AnyAsync(); +} diff --git a/Auth/Services/UserService.cs b/Auth/Services/UserService.cs new file mode 100644 index 0000000..535bcf8 --- /dev/null +++ b/Auth/Services/UserService.cs @@ -0,0 +1,32 @@ +using Auth.Models; +using MongoDB.Driver; + +namespace Auth.Services; + +public class UserService +{ + private readonly IMongoCollection _col; + + public UserService(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("users"); + + var keys = Builders.IndexKeys.Ascending(u => u.Username); + _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { Unique = true })); + } + + public Task GetByUsernameAsync(string username) => + _col.Find(u => u.Username == username).FirstOrDefaultAsync(); + + public Task CreateAsync(User user) => _col.InsertOneAsync(user); + + public Task UpdateAsync(User user) => + _col.ReplaceOneAsync(u => u.Id == user.Id, user); + + public Task> GetAllAsync() => + _col.Find(FilterDefinition.Empty).ToListAsync(); +} diff --git a/Auth/appsettings.json b/Auth/appsettings.json index 10f68b8..60fb5db 100644 --- a/Auth/appsettings.json +++ b/Auth/appsettings.json @@ -1,9 +1,7 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "GameDb" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "GameAuthApi", "Audience": "game-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, "AllowedHosts": "*" } diff --git a/micro-services.sln b/micro-services.sln new file mode 100644 index 0000000..20d3799 --- /dev/null +++ b/micro-services.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Auth", "Auth\Auth.csproj", "{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F82C87CC-7411-493D-A138-491A81FBCC32} + EndGlobalSection +EndGlobal