diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.HopFrame/.idea/.gitignore b/.idea/.idea.HopFrame/.idea/.gitignore new file mode 100644 index 0000000..4806007 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.HopFrame.iml +/projectSettingsUpdater.xml +/modules.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.HopFrame/.idea/indexLayout.xml b/.idea/.idea.HopFrame/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.HopFrame/.idea/vcs.xml b/.idea/.idea.HopFrame/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/DatabaseTest/.gitignore b/DatabaseTest/.gitignore new file mode 100644 index 0000000..a4bbe35 --- /dev/null +++ b/DatabaseTest/.gitignore @@ -0,0 +1,3 @@ +obj +bin +appsettings.Development.json \ No newline at end of file diff --git a/DatabaseTest/Controllers/TestController.cs b/DatabaseTest/Controllers/TestController.cs new file mode 100644 index 0000000..0104631 --- /dev/null +++ b/DatabaseTest/Controllers/TestController.cs @@ -0,0 +1,9 @@ +using HopFrame.Api.Controller; +using Microsoft.AspNetCore.Mvc; + +namespace DatabaseTest.Controllers; + +[ApiController] +public class TestController(DatabaseContext context) : SecurityController(context) { + +} \ No newline at end of file diff --git a/DatabaseTest/DatabaseContext.cs b/DatabaseTest/DatabaseContext.cs new file mode 100644 index 0000000..ac33081 --- /dev/null +++ b/DatabaseTest/DatabaseContext.cs @@ -0,0 +1,12 @@ +using HopFrame.Database; +using Microsoft.EntityFrameworkCore; + +namespace DatabaseTest; + +public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\DatabaseTest\\bin\\Debug\\net7.0\\test.db;Mode=ReadWrite;"); + } +} \ No newline at end of file diff --git a/DatabaseTest/DatabaseTest.csproj b/DatabaseTest/DatabaseTest.csproj new file mode 100644 index 0000000..936e228 --- /dev/null +++ b/DatabaseTest/DatabaseTest.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/DatabaseTest/Migrations/20240712130909_Initial.Designer.cs b/DatabaseTest/Migrations/20240712130909_Initial.Designer.cs new file mode 100644 index 0000000..385c9d8 --- /dev/null +++ b/DatabaseTest/Migrations/20240712130909_Initial.Designer.cs @@ -0,0 +1,75 @@ +// +using System; +using DatabaseTest; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240712130909_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.20"); + + modelBuilder.Entity("HopFrame.Database.Models.PermissionEntry", b => + { + b.Property("RecordId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.Property("PermissionText") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("RecordId"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.UserEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DatabaseTest/Migrations/20240712130909_Initial.cs b/DatabaseTest/Migrations/20240712130909_Initial.cs new file mode 100644 index 0000000..7cedbde --- /dev/null +++ b/DatabaseTest/Migrations/20240712130909_Initial.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Permissions", + columns: table => new + { + RecordId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PermissionText = table.Column(type: "TEXT", maxLength: 255, nullable: false), + OwnerId = table.Column(type: "INTEGER", nullable: false), + GrantedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Permissions", x => x.RecordId); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Username = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Password = table.Column(type: "TEXT", maxLength: 255, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Permissions"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/DatabaseTest/Migrations/20240712143345_Tokens.Designer.cs b/DatabaseTest/Migrations/20240712143345_Tokens.Designer.cs new file mode 100644 index 0000000..334bd32 --- /dev/null +++ b/DatabaseTest/Migrations/20240712143345_Tokens.Designer.cs @@ -0,0 +1,101 @@ +// +using System; +using DatabaseTest; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240712143345_Tokens")] + partial class Tokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.20"); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.PermissionEntry", b => + { + b.Property("RecordId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.Property("PermissionText") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("RecordId"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.UserEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("HopFrame.Security.Models.TokenEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(1) + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DatabaseTest/Migrations/20240712143345_Tokens.cs b/DatabaseTest/Migrations/20240712143345_Tokens.cs new file mode 100644 index 0000000..4aaf872 --- /dev/null +++ b/DatabaseTest/Migrations/20240712143345_Tokens.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + /// + public partial class Tokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tokens", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Type = table.Column(type: "INTEGER", maxLength: 1, nullable: false), + Token = table.Column(type: "TEXT", maxLength: 36, nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tokens", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Tokens"); + } + } +} diff --git a/DatabaseTest/Migrations/20240713083821_Security.Designer.cs b/DatabaseTest/Migrations/20240713083821_Security.Designer.cs new file mode 100644 index 0000000..de4bfa3 --- /dev/null +++ b/DatabaseTest/Migrations/20240713083821_Security.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using DatabaseTest; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240713083821_Security")] + partial class Security + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.PermissionEntry", b => + { + b.Property("RecordId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("PermissionText") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.HasKey("RecordId"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.TokenEntry", b => + { + b.Property("Token") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(1) + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.ToTable("Tokens"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.UserEntry", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DatabaseTest/Migrations/20240713083821_Security.cs b/DatabaseTest/Migrations/20240713083821_Security.cs new file mode 100644 index 0000000..9e581d8 --- /dev/null +++ b/DatabaseTest/Migrations/20240713083821_Security.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + /// + public partial class Security : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Tokens", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "Id", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "OwnerId", + table: "Permissions"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Users", + type: "TEXT", + maxLength: 36, + nullable: false, + oldClrType: typeof(long), + oldType: "INTEGER") + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Tokens", + type: "TEXT", + maxLength: 36, + nullable: false, + oldClrType: typeof(long), + oldType: "INTEGER"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Permissions", + type: "TEXT", + maxLength: 36, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddPrimaryKey( + name: "PK_Tokens", + table: "Tokens", + column: "Token"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Tokens", + table: "Tokens"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Permissions"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Users", + type: "INTEGER", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 36) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Tokens", + type: "INTEGER", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 36); + + migrationBuilder.AddColumn( + name: "Id", + table: "Tokens", + type: "INTEGER", + nullable: false, + defaultValue: 0L) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder.AddColumn( + name: "OwnerId", + table: "Permissions", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddPrimaryKey( + name: "PK_Tokens", + table: "Tokens", + column: "Id"); + } + } +} diff --git a/DatabaseTest/Migrations/DatabaseContextModelSnapshot.cs b/DatabaseTest/Migrations/DatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..d4c5d10 --- /dev/null +++ b/DatabaseTest/Migrations/DatabaseContextModelSnapshot.cs @@ -0,0 +1,97 @@ +// +using System; +using DatabaseTest; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DatabaseTest.Migrations +{ + [DbContext(typeof(DatabaseContext))] + partial class DatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.PermissionEntry", b => + { + b.Property("RecordId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("PermissionText") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.HasKey("RecordId"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.TokenEntry", b => + { + b.Property("Token") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(1) + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.ToTable("Tokens"); + }); + + modelBuilder.Entity("HopFrame.Database.Models.Entries.UserEntry", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DatabaseTest/Program.cs b/DatabaseTest/Program.cs new file mode 100644 index 0000000..8cc3933 --- /dev/null +++ b/DatabaseTest/Program.cs @@ -0,0 +1,60 @@ +using DatabaseTest; +using HopFrame.Api; +using HopFrame.Api.Controller; +using HopFrame.Security.Authentication; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers() + .AddController>(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext(); +builder.Services.AddHopFrameAuthentication(); +//builder.Logging.AddFilter>(options => options == LogLevel.None); + +builder.Services.AddSwaggerGen(c => { + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { + Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n + Enter 'Bearer' [space] and then your token in the text input below.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement {{ + new OpenApiSecurityScheme { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + ArraySegment.Empty + }}); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) { + app.UseSwagger(); + app.UseSwaggerUI(); +} + +//app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/DatabaseTest/Properties/launchSettings.json b/DatabaseTest/Properties/launchSettings.json new file mode 100644 index 0000000..6418a5c --- /dev/null +++ b/DatabaseTest/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:19326", + "sslPort": 44320 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7283;http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DatabaseTest/appsettings.json b/DatabaseTest/appsettings.json new file mode 100644 index 0000000..470eccb --- /dev/null +++ b/DatabaseTest/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "HopFrame.Security.Authentication.HopFrameAuthentication": "None" + } + }, + "AllowedHosts": "*" +} diff --git a/HopFrame.Api/Controller/SecurityController.cs b/HopFrame.Api/Controller/SecurityController.cs new file mode 100644 index 0000000..a9022b1 --- /dev/null +++ b/HopFrame.Api/Controller/SecurityController.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using System.Text; +using HopFrame.Api.Logic; +using HopFrame.Api.Models; +using HopFrame.Database; +using HopFrame.Database.Models.Entries; +using HopFrame.Security.Authentication; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Api.Controller; + +[ApiController] +[Route("authentication")] +public class SecurityController(TDbContext context) : ControllerBase where TDbContext : HopDbContextBase { + + private const string RefreshTokenType = "HopFrame.Security.RefreshToken"; + + [HttpPut("login")] + public async Task>> Login([FromBody] UserLogin login) { + var user = await context.Users.SingleOrDefaultAsync(user => user.Email == login.Email); + + if (user is null) + return LogicResult>.NotFound("The provided email address was not found"); + + var hashedPassword = EncryptionManager.Hash(login.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture))); + if (hashedPassword != user.Password) + return LogicResult>.Forbidden("The provided password is not correct"); + + var refreshToken = new TokenEntry { + CreatedAt = DateTime.Now, + Token = Guid.NewGuid().ToString(), + Type = TokenEntry.RefreshTokenType, + UserId = user.Id + }; + var accessToken = new TokenEntry { + CreatedAt = DateTime.Now, + Token = Guid.NewGuid().ToString(), + Type = TokenEntry.AccessTokenType, + UserId = user.Id + }; + + HttpContext.Response.Cookies.Append(RefreshTokenType, refreshToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + + await context.Tokens.AddRangeAsync(refreshToken, accessToken); + await context.SaveChangesAsync(); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpPost("register")] + public async Task>> Register([FromBody] UserRegister register) { + //TODO: Validate Password requirements + + if (await context.Users.AnyAsync(user => user.Username == register.Username || user.Email == register.Email)) + return LogicResult>.Conflict("Username or Email is already registered"); + + var user = new UserEntry { + CreatedAt = DateTime.Now, + Email = register.Email, + Username = register.Username, + Id = Guid.NewGuid().ToString() + }; + user.Password = EncryptionManager.Hash(register.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture))); + + await context.Users.AddAsync(user); + + var refreshToken = new TokenEntry { + CreatedAt = DateTime.Now, + Token = Guid.NewGuid().ToString(), + Type = TokenEntry.RefreshTokenType, + UserId = user.Id + }; + var accessToken = new TokenEntry { + CreatedAt = DateTime.Now, + Token = Guid.NewGuid().ToString(), + Type = TokenEntry.AccessTokenType, + UserId = user.Id + }; + + HttpContext.Response.Cookies.Append(RefreshTokenType, refreshToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + + await context.Tokens.AddRangeAsync(refreshToken, accessToken); + await context.SaveChangesAsync(); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpGet("authenticate")] + public async Task>> Authenticate() { + var refreshToken = HttpContext.Request.Cookies[RefreshTokenType]; + + if (string.IsNullOrEmpty(refreshToken)) + return LogicResult>.Conflict("Refresh token not provided"); + + var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType); + + if (token is null) + return LogicResult>.NotFound("Refresh token not valid"); + + if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) + return LogicResult>.Conflict("Refresh token is expired"); + + var accessToken = new TokenEntry { + CreatedAt = DateTime.Now, + Token = Guid.NewGuid().ToString(), + Type = TokenEntry.AccessTokenType, + UserId = token.UserId + }; + + await context.Tokens.AddAsync(accessToken); + await context.SaveChangesAsync(); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpDelete("logout"), Authorized] + public async Task Logout() { + var accessToken = HttpContext.User.GetAccessTokenId(); + var refreshToken = HttpContext.Request.Cookies[RefreshTokenType]; + + if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) + return LogicResult.Conflict("access or refresh token not provided"); + + var tokenEntries = await context.Tokens.Where(token => + (token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) || + (token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType)) + .ToArrayAsync(); + + if (tokenEntries.Length != 2) + return LogicResult.NotFound("One or more of the provided tokens was not found"); + + context.Tokens.Remove(tokenEntries[0]); + context.Tokens.Remove(tokenEntries[1]); + await context.SaveChangesAsync(); + + HttpContext.Response.Cookies.Delete(RefreshTokenType); + + return LogicResult.Ok(); + } + + [HttpDelete("delete"), Authorized] + public async Task Delete([FromBody] UserLogin login) { + var token = HttpContext.User.GetAccessTokenId(); + var userId = (await context.Tokens.SingleOrDefaultAsync(t => t.Token == token && t.Type == TokenEntry.AccessTokenType))?.UserId; + + if (string.IsNullOrEmpty(userId)) + return LogicResult.NotFound("Access token does not match any user"); + + var user = await context.Users.SingleAsync(user => user.Id == userId); + + var password = EncryptionManager.Hash(login.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture))); + if (user.Password != password) + return LogicResult.Forbidden("The provided password is not correct"); + + var tokens = await context.Tokens.Where(t => t.UserId == userId).ToArrayAsync(); + + context.Tokens.RemoveRange(tokens); + context.Users.Remove(user); + await context.SaveChangesAsync(); + + HttpContext.Response.Cookies.Delete(RefreshTokenType); + + return LogicResult.Ok(); + } + +} \ No newline at end of file diff --git a/HopFrame.Api/ControllerExtensions.cs b/HopFrame.Api/ControllerExtensions.cs new file mode 100644 index 0000000..e8d3fb4 --- /dev/null +++ b/HopFrame.Api/ControllerExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Api; + +public static class ControllerExtensions { + + public static IMvcBuilder AddController(this IMvcBuilder builder) where TController : ControllerBase { + return builder.AddApplicationPart(typeof(TController).Assembly); + } + +} \ No newline at end of file diff --git a/HopFrame.Api/EncryptionManager.cs b/HopFrame.Api/EncryptionManager.cs new file mode 100644 index 0000000..8b29a57 --- /dev/null +++ b/HopFrame.Api/EncryptionManager.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace HopFrame.Api; + +public static class EncryptionManager { + + public static string Hash(string input, byte[] salt, KeyDerivationPrf method = KeyDerivationPrf.HMACSHA256) { + return Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: input, + salt: salt, + prf: method, + iterationCount: 100000, + numBytesRequested: 256 / 8 + )); + } + +} \ No newline at end of file diff --git a/HopFrame.Api/HopFrame.Api.csproj b/HopFrame.Api/HopFrame.Api.csproj new file mode 100644 index 0000000..a6269a1 --- /dev/null +++ b/HopFrame.Api/HopFrame.Api.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + latest + enable + disable + + + + + + + + diff --git a/HopFrame.Api/Logic/ControllerBaseExtension.cs b/HopFrame.Api/Logic/ControllerBaseExtension.cs new file mode 100644 index 0000000..27fedc9 --- /dev/null +++ b/HopFrame.Api/Logic/ControllerBaseExtension.cs @@ -0,0 +1,50 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace HopFrame.Api.Logic; + +public static class ControllerBaseExtension { + public static ActionResult FromLogicResult(this ControllerBase controller, ILogicResult result) { + switch (result.State) { + case LogicResultState.Ok: + return controller.Ok(); + + case LogicResultState.BadRequest: + return controller.StatusCode((int)HttpStatusCode.BadRequest, result.Message); + + case LogicResultState.Forbidden: + return controller.StatusCode((int)HttpStatusCode.Forbidden, result.Message); + + case LogicResultState.NotFound: + return controller.StatusCode((int)HttpStatusCode.NotFound, result.Message); + + case LogicResultState.Conflict: + return controller.StatusCode((int)HttpStatusCode.Conflict, result.Message); + + default: + throw new Exception("An unhandled result has occurred as a result of a service call."); + } + } + + public static ActionResult FromLogicResult(this ControllerBase controller, ILogicResult result) { + switch (result.State) { + case LogicResultState.Ok: + return controller.Ok(result.Data); + + case LogicResultState.BadRequest: + return controller.StatusCode((int)HttpStatusCode.BadRequest, result.Message); + + case LogicResultState.Forbidden: + return controller.StatusCode((int)HttpStatusCode.Forbidden, result.Message); + + case LogicResultState.NotFound: + return controller.StatusCode((int)HttpStatusCode.NotFound, result.Message); + + case LogicResultState.Conflict: + return controller.StatusCode((int)HttpStatusCode.Conflict, result.Message); + + default: + throw new Exception("An unhandled result has occurred as a result of a service call."); + } + } +} \ No newline at end of file diff --git a/HopFrame.Api/Logic/ILogicResult.cs b/HopFrame.Api/Logic/ILogicResult.cs new file mode 100644 index 0000000..6edb70a --- /dev/null +++ b/HopFrame.Api/Logic/ILogicResult.cs @@ -0,0 +1,19 @@ +namespace HopFrame.Api.Logic; + +public interface ILogicResult { + LogicResultState State { get; set; } + + string Message { get; set; } + + bool IsSuccessful { get; } +} + +public interface ILogicResult { + LogicResultState State { get; set; } + + T Data { get; set; } + + string Message { get; set; } + + bool IsSuccessful { get; } +} \ No newline at end of file diff --git a/HopFrame.Api/Logic/LogicResult.cs b/HopFrame.Api/Logic/LogicResult.cs new file mode 100644 index 0000000..cc3254a --- /dev/null +++ b/HopFrame.Api/Logic/LogicResult.cs @@ -0,0 +1,170 @@ +namespace HopFrame.Api.Logic; + +public class LogicResult : ILogicResult { + public LogicResultState State { get; set; } + + public string Message { get; set; } + + public bool IsSuccessful => State == LogicResultState.Ok; + + public static LogicResult Ok() { + return new LogicResult() { + State = LogicResultState.Ok + }; + } + + public static LogicResult BadRequest() { + return new LogicResult() { + State = LogicResultState.BadRequest + }; + } + + public static LogicResult BadRequest(string message) { + return new LogicResult() { + State = LogicResultState.BadRequest, + Message = message + }; + } + + public static LogicResult Forbidden() { + return new LogicResult() { + State = LogicResultState.Forbidden + }; + } + + public static LogicResult Forbidden(string message) { + return new LogicResult() { + State = LogicResultState.Forbidden, + Message = message + }; + } + + public static LogicResult NotFound() { + return new LogicResult() { + State = LogicResultState.NotFound + }; + } + + public static LogicResult NotFound(string message) { + return new LogicResult() { + State = LogicResultState.NotFound, + Message = message + }; + } + + public static LogicResult Conflict() { + return new LogicResult() { + State = LogicResultState.Conflict + }; + } + + public static LogicResult Conflict(string message) { + return new LogicResult() { + State = LogicResultState.Conflict, + Message = message + }; + } + + public static LogicResult Forward(LogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } + + public static LogicResult Forward(ILogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } +} + +public class LogicResult : ILogicResult { + public LogicResultState State { get; set; } + + public T Data { get; set; } + + public string Message { get; set; } + + public bool IsSuccessful => State == LogicResultState.Ok; + + public static LogicResult Ok() { + return new LogicResult() { + State = LogicResultState.Ok + }; + } + + public static LogicResult Ok(T result) { + return new LogicResult() { + State = LogicResultState.Ok, + Data = result + }; + } + + public static LogicResult BadRequest() { + return new LogicResult() { + State = LogicResultState.BadRequest + }; + } + + public static LogicResult BadRequest(string message) { + return new LogicResult() { + State = LogicResultState.BadRequest, + Message = message + }; + } + + public static LogicResult Forbidden() { + return new LogicResult() { + State = LogicResultState.Forbidden + }; + } + + public static LogicResult Forbidden(string message) { + return new LogicResult() { + State = LogicResultState.Forbidden, + Message = message + }; + } + + public static LogicResult NotFound() { + return new LogicResult() { + State = LogicResultState.NotFound + }; + } + + public static LogicResult NotFound(string message) { + return new LogicResult() { + State = LogicResultState.NotFound, + Message = message + }; + } + + public static LogicResult Conflict() { + return new LogicResult() { + State = LogicResultState.Conflict + }; + } + + public static LogicResult Conflict(string message) { + return new LogicResult() { + State = LogicResultState.Conflict, + Message = message + }; + } + + public static LogicResult Forward(ILogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } + + public static LogicResult Forward(ILogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } +} \ No newline at end of file diff --git a/HopFrame.Api/Logic/LogicResultState.cs b/HopFrame.Api/Logic/LogicResultState.cs new file mode 100644 index 0000000..69d5b90 --- /dev/null +++ b/HopFrame.Api/Logic/LogicResultState.cs @@ -0,0 +1,9 @@ +namespace HopFrame.Api.Logic; + +public enum LogicResultState { + Ok, + BadRequest, + Forbidden, + NotFound, + Conflict +} \ No newline at end of file diff --git a/HopFrame.Api/Models/SingleValueResult.cs b/HopFrame.Api/Models/SingleValueResult.cs new file mode 100644 index 0000000..b1eb551 --- /dev/null +++ b/HopFrame.Api/Models/SingleValueResult.cs @@ -0,0 +1,13 @@ +namespace HopFrame.Api.Models; + +public struct SingleValueResult(T value) { + public T Value { get; set; } = value; + + public static implicit operator T(SingleValueResult v) { + return v.Value; + } + + public static implicit operator SingleValueResult(T v) { + return new SingleValueResult(v); + } +} \ No newline at end of file diff --git a/HopFrame.Api/Models/UserLogin.cs b/HopFrame.Api/Models/UserLogin.cs new file mode 100644 index 0000000..ccaac7e --- /dev/null +++ b/HopFrame.Api/Models/UserLogin.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Api.Models; + +public struct UserLogin { + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Api/Models/UserRegister.cs b/HopFrame.Api/Models/UserRegister.cs new file mode 100644 index 0000000..aacafdc --- /dev/null +++ b/HopFrame.Api/Models/UserRegister.cs @@ -0,0 +1,7 @@ +namespace HopFrame.Api.Models; + +public struct UserRegister { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/HopDbContextBase.cs b/HopFrame.Database/HopDbContextBase.cs new file mode 100644 index 0000000..e5872bf --- /dev/null +++ b/HopFrame.Database/HopDbContextBase.cs @@ -0,0 +1,22 @@ +using HopFrame.Database.Models.Entries; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database; + +public class HopDbContextBase : DbContext { + + public HopDbContextBase() {} + + public HopDbContextBase(DbContextOptions options) : base(options) {} + + public virtual DbSet Users { get; set; } + public virtual DbSet Permissions { get; set; } + public virtual DbSet Tokens { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} \ No newline at end of file diff --git a/HopFrame.Database/HopFrame.Database.csproj b/HopFrame.Database/HopFrame.Database.csproj new file mode 100644 index 0000000..b0a20a0 --- /dev/null +++ b/HopFrame.Database/HopFrame.Database.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + latest + enable + disable + + + + + + + diff --git a/HopFrame.Database/Models/Entries/PermissionEntry.cs b/HopFrame.Database/Models/Entries/PermissionEntry.cs new file mode 100644 index 0000000..2f8bdae --- /dev/null +++ b/HopFrame.Database/Models/Entries/PermissionEntry.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace HopFrame.Database.Models.Entries; + +public sealed class PermissionEntry { + [Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long RecordId { get; set; } + + [Required, MaxLength(255)] + public string PermissionText { get; set; } + + [Required, MinLength(36), MaxLength(36)] + public string UserId { get; set; } + + [Required] + public DateTime GrantedAt { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/TokenEntry.cs b/HopFrame.Database/Models/Entries/TokenEntry.cs new file mode 100644 index 0000000..d33b307 --- /dev/null +++ b/HopFrame.Database/Models/Entries/TokenEntry.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace HopFrame.Database.Models.Entries; + +public class TokenEntry { + public const int RefreshTokenType = 0; + public const int AccessTokenType = 1; + + /// + /// Defines the Type of the stored Token + /// 0: Refresh token + /// 1: Access token + /// + [Required, MinLength(1), MaxLength(1)] + public int Type { get; set; } + + [Key, Required, MinLength(36), MaxLength(36)] + public string Token { get; set; } + + [Required, MinLength(36), MaxLength(36)] + public string UserId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/UserEntry.cs b/HopFrame.Database/Models/Entries/UserEntry.cs new file mode 100644 index 0000000..2bc1a12 --- /dev/null +++ b/HopFrame.Database/Models/Entries/UserEntry.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace HopFrame.Database.Models.Entries; + +public class UserEntry { + [Key, Required, MinLength(36), MaxLength(36)] + public string Id { get; set; } + + [MaxLength(50)] + public string Username { get; set; } + + [Required, MaxLength(50), EmailAddress] + public string Email { get; set; } + + [Required, MinLength(8), MaxLength(255)] + public string Password { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/ModelExtensions.cs b/HopFrame.Database/Models/ModelExtensions.cs new file mode 100644 index 0000000..b0cc7a6 --- /dev/null +++ b/HopFrame.Database/Models/ModelExtensions.cs @@ -0,0 +1,23 @@ +using HopFrame.Database.Models.Entries; + +namespace HopFrame.Database.Models; + +public static class ModelExtensions { + + public static User ToUserModel(this UserEntry entry, HopDbContextBase contextBase) { + var user = new User { + Id = Guid.Parse(entry.Id), + Username = entry.Username, + Email = entry.Email, + CreatedAt = entry.CreatedAt + }; + + user.Permissions = contextBase.Permissions + .Where(perm => perm.UserId == entry.Id) + .Select(perm => perm.PermissionText) + .ToList(); + + return user; + } + +} \ No newline at end of file diff --git a/HopFrame.Database/Models/User.cs b/HopFrame.Database/Models/User.cs new file mode 100644 index 0000000..cbedd0c --- /dev/null +++ b/HopFrame.Database/Models/User.cs @@ -0,0 +1,9 @@ +namespace HopFrame.Database.Models; + +public class User { + public Guid Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + public DateTime CreatedAt { get; set; } + public IList Permissions { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/HopFrame.Security/Authentication/HopFrameAuthentication.cs new file mode 100644 index 0000000..e021a0c --- /dev/null +++ b/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using HopFrame.Database; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace HopFrame.Security.Authentication; + +public class HopFrameAuthentication : AuthenticationHandler where TDbContext : HopDbContextBase { + + public const string SchemeName = "HopCore.Authentication"; + public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0); + public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0); + + private readonly TDbContext _context; + + public HopFrameAuthentication(IOptionsMonitor options, ILoggerFactory logger, + UrlEncoder encoder, ISystemClock clock, TDbContext context) : base(options, logger, encoder, clock) { + _context = context; + } + + protected override async Task HandleAuthenticateAsync() { + var accessToken = Request.Headers["Authorization"].ToString(); + if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); + + var tokenEntry = await _context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken); + + if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist"); + if (tokenEntry.CreatedAt + AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired"); + + if (!(await _context.Users.AnyAsync(user => user.Id == tokenEntry.UserId))) + return AuthenticateResult.Fail("The provided Access Token does not match any user"); + + var claims = new List { + new(HopFrameClaimTypes.AccessTokenId, accessToken), + new(HopFrameClaimTypes.UserId, tokenEntry.UserId.ToString()) + }; + + var permissions = await _context.Permissions + .Where(perm => perm.UserId == tokenEntry.UserId) + .Select(perm => perm.PermissionText) + .ToListAsync(); + + claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); + + var principal = new ClaimsPrincipal(); + principal.AddIdentity(new ClaimsIdentity(claims, SchemeName)); + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs new file mode 100644 index 0000000..e6faedc --- /dev/null +++ b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -0,0 +1,13 @@ +using HopFrame.Database; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Security.Authentication; + +public static class HopFrameAuthenticationExtensions { + + public static AuthenticationBuilder AddHopFrameAuthentication(this IServiceCollection service) where TDbContext : HopDbContextBase { + return service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme>(HopFrameAuthentication.SchemeName, _ => {}); + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Authorization/AuthorizedAttribute.cs b/HopFrame.Security/Authorization/AuthorizedAttribute.cs new file mode 100644 index 0000000..0180b52 --- /dev/null +++ b/HopFrame.Security/Authorization/AuthorizedAttribute.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; + +namespace HopFrame.Security.Authorization; + +public class AuthorizedAttribute : TypeFilterAttribute { + public AuthorizedAttribute(params string[] permission) : base(typeof(AuthorizedFilter)) { + Arguments = new object[] { permission }; + } +} \ No newline at end of file diff --git a/HopFrame.Security/Authorization/AuthorizedFilter.cs b/HopFrame.Security/Authorization/AuthorizedFilter.cs new file mode 100644 index 0000000..e7f8859 --- /dev/null +++ b/HopFrame.Security/Authorization/AuthorizedFilter.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace HopFrame.Security.Authorization; + +public class AuthorizedFilter : IAuthorizationFilter { + private readonly string[] _permissions; + + public AuthorizedFilter(params string[] permissions) { + _permissions = permissions; + } + + public void OnAuthorization(AuthorizationFilterContext context) { + if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return; + + if (context.HttpContext.User.Identity?.IsAuthenticated == false) { + context.Result = new UnauthorizedResult(); + return; + } + + //TODO: Check Permissions + } +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/HopFrameClaimTypes.cs b/HopFrame.Security/Claims/HopFrameClaimTypes.cs new file mode 100644 index 0000000..595f76c --- /dev/null +++ b/HopFrame.Security/Claims/HopFrameClaimTypes.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; + +namespace HopFrame.Security.Claims; + +public static class HopFrameClaimTypes { + public const string AccessTokenId = "HopFrame.AccessTokenId"; + public const string UserId = "HopFrame.UserId"; + public const string Permission = "HopFrame.Permission"; + + public static string GetAccessTokenId(this ClaimsPrincipal principal) { + return principal.FindFirstValue(AccessTokenId); + } + + public static string GetUserId(this ClaimsPrincipal principal) { + return principal.FindFirstValue(UserId); + } + + public static string[] GetPermissions(this ClaimsPrincipal principal) { + return principal.FindAll(Permission).Select(claim => claim.Value).ToArray(); + } +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/ITokenContextBase.cs b/HopFrame.Security/Claims/ITokenContextBase.cs new file mode 100644 index 0000000..66788bc --- /dev/null +++ b/HopFrame.Security/Claims/ITokenContextBase.cs @@ -0,0 +1,9 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Security.Claims; + +public interface ITokenContextBase { + bool IsAuthenticated { get; } + User User { get; } + Guid AccessToken { get; } +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/TokenContextImplementor.cs b/HopFrame.Security/Claims/TokenContextImplementor.cs new file mode 100644 index 0000000..a66d40b --- /dev/null +++ b/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -0,0 +1,23 @@ +using HopFrame.Database; +using HopFrame.Database.Models; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Security.Claims; + +public class TokenContextImplementor : ITokenContextBase { + private readonly IHttpContextAccessor _accessor; + private readonly HopDbContextBase _context; + + public TokenContextImplementor(IHttpContextAccessor accessor, HopDbContextBase context) { + _accessor = accessor; + _context = context; + } + + public bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true; + + public User User => _context.Users + .SingleOrDefault(user => user.Id == _accessor.HttpContext.User.GetUserId())? + .ToUserModel(_context); + + public Guid AccessToken => Guid.Parse(_accessor.HttpContext?.User.GetAccessTokenId() ?? string.Empty); +} \ No newline at end of file diff --git a/HopFrame.Security/HopFrame.Security.csproj b/HopFrame.Security/HopFrame.Security.csproj new file mode 100644 index 0000000..c99bc9b --- /dev/null +++ b/HopFrame.Security/HopFrame.Security.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + latest + enable + disable + HopFrame.Security + + + + + + + + + + + + diff --git a/HopFrame.sln b/HopFrame.sln new file mode 100644 index 0000000..8242697 --- /dev/null +++ b/HopFrame.sln @@ -0,0 +1,39 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{58703056-8DAD-4221-BBE3-42425D2F4929}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseTest", "DatabaseTest\DatabaseTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "HopFrame.Api\HopFrame.Api.csproj", "{1E821490-AEDC-4F55-B758-52F4FADAB53A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.Build.0 = Debug|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.ActiveCfg = Release|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.Build.0 = Release|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.Build.0 = Release|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.Build.0 = Release|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {58703056-8DAD-4221-BBE3-42425D2F4929} + EndGlobalSection +EndGlobal diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user new file mode 100644 index 0000000..3b58ba2 --- /dev/null +++ b/HopFrame.sln.DotSettings.user @@ -0,0 +1,4 @@ + + <AssemblyExplorer> + <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> +</AssemblyExplorer> \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..2ddda36 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file