diff --git a/backend/API/API.csproj b/backend/API/API.csproj index 4f63c50..b2cc26d 100644 --- a/backend/API/API.csproj +++ b/backend/API/API.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/API/JwtOptions.cs b/backend/API/JwtOptions.cs deleted file mode 100644 index 8855691..0000000 --- a/backend/API/JwtOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace API -{ - public class JwtOptions - { - public string Issuer { get; set; } - public string Audience { get; set; } - public int Lifetime { get; set; } - public string SigningKey { get; set; } - } -} diff --git a/backend/API/Program.cs b/backend/API/Program.cs index 8f561b7..20f1ef8 100644 --- a/backend/API/Program.cs +++ b/backend/API/Program.cs @@ -1,6 +1,9 @@ using API; using API.Filters; using Business; +using Business.Authorization; +using Business.Services; +using Database.Models; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.IdentityModel.Tokens; @@ -26,29 +29,54 @@ -// Bearer Token Authentication -var jwtOptions = builder.Configuration.GetSection("Jwt").Get(); +// At the top where your services are registered, add: +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Replace your current JWT authentication setup with: +var jwtSection = builder.Configuration.GetSection("Jwt"); +builder.Services.Configure(jwtSection); +var jwtOptions = jwtSection.Get(); + +if (jwtOptions == null) +{ + throw new InvalidOperationException("JWT configuration is missing in appsettings.json"); +} + builder.Services.AddSingleton(jwtOptions); -builder.Services.AddAuthentication(). - AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters { - // to keep the token string after getting the info - // then it can be accessed using HttpContext - options.SaveToken = true; - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtOptions.Issuer, - - ValidateAudience = true, - ValidAudience = jwtOptions.Audience, - - ValidateIssuerSigningKey = true, - IssuerSigningKey = - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)) - }; - }); + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)) + }; +}); + +// Add authorization after authentication +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("ClubMember", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Member))); + options.AddPolicy("ClubModerator", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Moderator))); + options.AddPolicy("ClubOwner", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Owner))); +}); + +// In the middleware section (after app.UseHttpsRedirection()): // builder.Services.AddScoped(); @@ -66,6 +94,8 @@ app.UseSwaggerUI(); } +app.UseAuthentication(); +app.UseAuthorization(); app.UseHttpsRedirection(); app.UseCors(static builder => builder.AllowAnyMethod() diff --git a/backend/API/appsettings.json b/backend/API/appsettings.json index a86a132..d750be2 100644 --- a/backend/API/appsettings.json +++ b/backend/API/appsettings.json @@ -38,5 +38,11 @@ "ConnectionStrings": { "DefaultConnection": "Server=.; Database = StudyCircle; Integrated Security = SSPI; TrustServerCertificate = True;" }, + "Jwt": { + "Issuer": "StudyCircle", + "Audience": "StudyCircleClient", + "Lifetime": 60, + "SigningKey": "StudyCircle_SuperSecretKey_12345!@#$%^&*()_+QWERTY" + }, "AllowedHosts": "*" } diff --git a/backend/Business/Authorization/ClubAuthorizationHandler.cs b/backend/Business/Authorization/ClubAuthorizationHandler.cs new file mode 100644 index 0000000..81648a3 --- /dev/null +++ b/backend/Business/Authorization/ClubAuthorizationHandler.cs @@ -0,0 +1,41 @@ +using Database.Models; +using Microsoft.AspNetCore.Authorization; + +namespace Business.Authorization +{ + public class ClubAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + ClubAuthorizationRequirement requirement) + { + var user = context.User; + var clubId = requirement.ClubId; + var requiredRole = requirement.RequiredRole; + + if (user.IsInRole($"{requiredRole}#{clubId}")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (requiredRole == ClubRole.Member) + { + if (user.IsInRole($"{ClubRole.Moderator}#{clubId}") || + user.IsInRole($"{ClubRole.Owner}#{clubId}")) + { + context.Succeed(requirement); + } + } + else if (requiredRole == ClubRole.Moderator) + { + if (user.IsInRole($"{ClubRole.Owner}#{clubId}")) + { + context.Succeed(requirement); + } + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/backend/Business/Authorization/ClubAuthorizationRequirement.cs b/backend/Business/Authorization/ClubAuthorizationRequirement.cs new file mode 100644 index 0000000..ce778f6 --- /dev/null +++ b/backend/Business/Authorization/ClubAuthorizationRequirement.cs @@ -0,0 +1,17 @@ +using Database.Models; +using Microsoft.AspNetCore.Authorization; + +namespace Business.Authorization +{ + public class ClubAuthorizationRequirement : IAuthorizationRequirement + { + public string ClubId { get; } + public ClubRole RequiredRole { get; } + + public ClubAuthorizationRequirement(string clubId, ClubRole requiredRole) + { + ClubId = clubId; + RequiredRole = requiredRole; + } + } +} \ No newline at end of file diff --git a/backend/Business/Business.csproj b/backend/Business/Business.csproj index 16a5c07..4e37d3a 100644 --- a/backend/Business/Business.csproj +++ b/backend/Business/Business.csproj @@ -7,6 +7,7 @@ + diff --git a/backend/Business/DTOs/AuthenticationDTOs.cs b/backend/Business/DTOs/AuthenticationDTOs.cs new file mode 100644 index 0000000..240a330 --- /dev/null +++ b/backend/Business/DTOs/AuthenticationDTOs.cs @@ -0,0 +1,38 @@ +namespace Business.DTOs +{ + public class RegisterDto + { + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; + public string Email { get; set; } = null!; + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public string? City { get; set; } + public string? Country { get; set; } + } + + public class LoginDto + { + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; + } + + public class AuthResponseDto + { + public bool Success { get; set; } + public string? Token { get; set; } + public string? Message { get; set; } + public UserDto? User { get; set; } + } + + public class UserDto + { + public int Id { get; set; } + public string Username { get; set; } = null!; + public string Email { get; set; } = null!; + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public string? City { get; set; } + public string? Country { get; set; } + } +} \ No newline at end of file diff --git a/backend/Business/Extensions/ServiceCollectionExtensions.cs b/backend/Business/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8e225a1 --- /dev/null +++ b/backend/Business/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using API; +using Business.Authorization; +using Business.Services; +using Database.Models; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace Business.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddClubAuthorization( + this IServiceCollection services, + IConfiguration configuration) + { + var jwtOptions = configuration.GetSection("Jwt").Get(); + services.Configure(configuration.GetSection("Jwt")); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(jwtOptions.SigningKey)) + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy("ClubMember", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Member))); + options.AddPolicy("ClubModerator", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Moderator))); + options.AddPolicy("ClubOwner", policy => + policy.Requirements.Add(new ClubAuthorizationRequirement("", ClubRole.Owner))); + }); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} \ No newline at end of file diff --git a/backend/Business/Services/AuthService.cs b/backend/Business/Services/AuthService.cs new file mode 100644 index 0000000..5ab4870 --- /dev/null +++ b/backend/Business/Services/AuthService.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography; +using Business.DTOs; +using Database.Models; +using Database; +using Microsoft.EntityFrameworkCore; + +namespace Business.Services +{ + public class AuthService : IAuthService + { + private readonly AppDbContext _context; + private readonly IJwtService _jwtService; + + public AuthService(AppDbContext context, IJwtService jwtService) + { + _context = context; + _jwtService = jwtService; + } + + public async Task RegisterAsync(RegisterDto registerDto) + { + if (await UserExistsAsync(registerDto.Username)) + { + return new AuthResponseDto + { + Success = false, + Message = "Username already exists" + }; + } + + if (await EmailExistsAsync(registerDto.Email)) + { + return new AuthResponseDto + { + Success = false, + Message = "Email already exists" + }; + } + + var user = new User + { + Username = registerDto.Username, + Password = HashPassword(registerDto.Password), + Email = registerDto.Email, + FirstName = registerDto.FirstName, + LastName = registerDto.LastName, + City = registerDto.City, + Country = registerDto.Country + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + var roles = new List(); + var token = _jwtService.GenerateToken(user, roles); + + return new AuthResponseDto + { + Success = true, + Token = token, + User = MapToUserDto(user) + }; + } + + public async Task LoginAsync(LoginDto loginDto) + { + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Username == loginDto.Username); + + if (user == null || !VerifyPassword(loginDto.Password, user.Password)) + { + return new AuthResponseDto + { + Success = false, + Message = "Invalid username or password" + }; + } + + var roles = await _context.ClubMembers + .Where(cm => cm.UserId == user.Id) + .Select(cm => new ClubRoleAssignment + { + UserId = cm.UserId, + ClubId = cm.ClubId, + Role = GetRoleFromMember(cm) + }) + .ToListAsync(); + + var token = _jwtService.GenerateToken(user, roles); + + return new AuthResponseDto + { + Success = true, + Token = token, + User = MapToUserDto(user) + }; + } + + public async Task UserExistsAsync(string username) + { + return await _context.Users + .AnyAsync(u => u.Username == username); + } + + public async Task EmailExistsAsync(string email) + { + return await _context.Users + .AnyAsync(u => u.Email == email); + } + + private static string HashPassword(string password) + { + using var hmac = new HMACSHA512(); + var salt = hmac.Key; + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(password)); + + byte[] hashBytes = new byte[salt.Length + hash.Length]; + Array.Copy(salt, 0, hashBytes, 0, salt.Length); + Array.Copy(hash, 0, hashBytes, salt.Length, hash.Length); + + return Convert.ToBase64String(hashBytes); + } + + private static bool VerifyPassword(string password, string storedHash) + { + byte[] hashBytes = Convert.FromBase64String(storedHash); + + byte[] salt = new byte[128]; + Array.Copy(hashBytes, 0, salt, 0, salt.Length); + + using var hmac = new HMACSHA512(salt); + var computedHash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(password)); + + for (int i = 0; i < computedHash.Length; i++) + { + if (computedHash[i] != hashBytes[salt.Length + i]) + return false; + } + + return true; + } + + private static ClubRole GetRoleFromMember(ClubMember member) + { + if (member.IsOwner) return ClubRole.Owner; + if (member.IsModerator) return ClubRole.Moderator; + return ClubRole.Member; + } + + private static UserDto MapToUserDto(User user) + { + return new UserDto + { + Id = user.Id, + Username = user.Username, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + City = user.City, + Country = user.Country + }; + } + } +} diff --git a/backend/Business/Services/IAuthService.cs b/backend/Business/Services/IAuthService.cs new file mode 100644 index 0000000..49952e7 --- /dev/null +++ b/backend/Business/Services/IAuthService.cs @@ -0,0 +1,12 @@ +using Business.DTOs; + +namespace Business.Services +{ + public interface IAuthService + { + Task RegisterAsync(RegisterDto registerDto); + Task LoginAsync(LoginDto loginDto); + Task UserExistsAsync(string username); + Task EmailExistsAsync(string email); + } +} \ No newline at end of file diff --git a/backend/Business/Services/IJwtService.cs b/backend/Business/Services/IJwtService.cs new file mode 100644 index 0000000..285128f --- /dev/null +++ b/backend/Business/Services/IJwtService.cs @@ -0,0 +1,11 @@ +using Database.Models; +using Database; +using System.Security.Claims; + +namespace Business.Services +{ + public interface IJwtService + { + string GenerateToken(User user, IEnumerable roles); + } +} \ No newline at end of file diff --git a/backend/Business/Services/JwtOptions.cs b/backend/Business/Services/JwtOptions.cs new file mode 100644 index 0000000..a7162ac --- /dev/null +++ b/backend/Business/Services/JwtOptions.cs @@ -0,0 +1,10 @@ +namespace API +{ + public class JwtOptions + { + public required string Issuer { get; set; } + public required string Audience { get; set; } + public int Lifetime { get; set; } + public required string SigningKey { get; set; } + } +} diff --git a/backend/Business/Services/JwtService.cs b/backend/Business/Services/JwtService.cs new file mode 100644 index 0000000..edbd1c5 --- /dev/null +++ b/backend/Business/Services/JwtService.cs @@ -0,0 +1,48 @@ +using Database.Models; +using Database; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using API; + +namespace Business.Services +{ + public class JwtService : IJwtService + { + private readonly JwtOptions _jwtOptions; + + public JwtService(JwtOptions jwtOptions) + { + _jwtOptions = jwtOptions; + } + + public string GenerateToken(User user, IEnumerable roles) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email) + }; + + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, $"{role.Role}#{role.ClubId}")); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_jwtOptions.Lifetime), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + } +} \ No newline at end of file diff --git a/backend/Database/Database.csproj b/backend/Database/Database.csproj index d42e419..5b03822 100644 --- a/backend/Database/Database.csproj +++ b/backend/Database/Database.csproj @@ -7,6 +7,7 @@ + all diff --git a/backend/Database/Models/ClubRole.cs b/backend/Database/Models/ClubRole.cs new file mode 100644 index 0000000..d0ad8d0 --- /dev/null +++ b/backend/Database/Models/ClubRole.cs @@ -0,0 +1,9 @@ +namespace Database.Models +{ + public enum ClubRole + { + Member = 1, + Moderator = 2, + Owner = 3 + } +} \ No newline at end of file diff --git a/backend/Database/Models/ClubRoleAssignment.cs b/backend/Database/Models/ClubRoleAssignment.cs new file mode 100644 index 0000000..f33e58d --- /dev/null +++ b/backend/Database/Models/ClubRoleAssignment.cs @@ -0,0 +1,9 @@ +namespace Database.Models +{ + public class ClubRoleAssignment + { + public int UserId { get; set; } + public int ClubId { get; set; } + public ClubRole Role { get; set; } + } +} \ No newline at end of file diff --git a/backend/Shared/Shared.csproj b/backend/Shared/Shared.csproj index fa71b7a..e2f7b99 100644 --- a/backend/Shared/Shared.csproj +++ b/backend/Shared/Shared.csproj @@ -6,4 +6,8 @@ enable + + + +