From 5cef6d72e42bfbfea6c2ca112a949426a8b36b96 Mon Sep 17 00:00:00 2001 From: LittleSheep <littlesheep.code@hotmail.com> Date: Fri, 11 Apr 2025 00:23:55 +0800 Subject: [PATCH] :sparkles: Casbin permission check --- DysonNetwork.Sphere/Account/Account.cs | 2 + DysonNetwork.Sphere/Auth/AuthController.cs | 99 +++-- DysonNetwork.Sphere/Auth/AuthService.cs | 96 ++++- DysonNetwork.Sphere/Auth/CasbinRequirement.cs | 34 ++ DysonNetwork.Sphere/Auth/Session.cs | 2 +- DysonNetwork.Sphere/Casbin.conf | 14 + .../DysonNetwork.Sphere.csproj | 2 + ...20250410150812_AddAuthSession.Designer.cs} | 12 +- ...on.cs => 20250410150812_AddAuthSession.cs} | 2 +- .../20250410161354_AddSuperUser.Designer.cs | 365 ++++++++++++++++++ .../Migrations/20250410161354_AddSuperUser.cs | 41 ++ .../Migrations/AppDatabaseModelSnapshot.cs | 20 +- DysonNetwork.Sphere/Program.cs | 20 + DysonNetwork.Sphere/appsettings.json | 3 +- 14 files changed, 640 insertions(+), 72 deletions(-) create mode 100644 DysonNetwork.Sphere/Auth/CasbinRequirement.cs create mode 100644 DysonNetwork.Sphere/Casbin.conf rename DysonNetwork.Sphere/Migrations/{20250409150800_AddAuthSession.Designer.cs => 20250410150812_AddAuthSession.Designer.cs} (98%) rename DysonNetwork.Sphere/Migrations/{20250409150800_AddAuthSession.cs => 20250410150812_AddAuthSession.cs} (98%) create mode 100644 DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs create mode 100644 DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index b866aae..ee53561 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -9,6 +9,8 @@ public class Account : BaseModel public long Id { get; set; } [MaxLength(256)] public string Name { get; set; } = string.Empty; [MaxLength(256)] public string Nick { get; set; } = string.Empty; + [MaxLength(32)] public string Language { get; set; } = string.Empty; + public bool IsSuperuser { get; set; } = false; public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs index cf1a7e0..553c588 100644 --- a/DysonNetwork.Sphere/Auth/AuthController.cs +++ b/DysonNetwork.Sphere/Auth/AuthController.cs @@ -16,8 +16,8 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService { [Required] [MaxLength(256)] public string Account { get; set; } = string.Empty; [MaxLength(512)] public string? DeviceId { get; set; } - public List<string> Claims { get; set; } = new(); public List<string> Audiences { get; set; } = new(); + public List<string> Scopes { get; set; } = new(); } [HttpPost("challenge")] @@ -34,8 +34,8 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService Account = account, ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), StepTotal = 1, - Claims = request.Claims, Audiences = request.Audiences, + Scopes = request.Scopes, IpAddress = ipAddress, UserAgent = userAgent, DeviceId = request.DeviceId, @@ -46,6 +46,18 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService return challenge; } + [HttpGet("challenge/{id}/factors")] + public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id) + { + var challenge = await db.AuthChallenges + .Include(e => e.Account) + .Include(e => e.Account.AuthFactors) + .Where(e => e.Id == id).FirstOrDefaultAsync(); + return challenge is null + ? new NotFoundObjectResult("Auth challenge was not found.") + : challenge.Account.AuthFactors.ToList(); + } + public class PerformChallengeRequest { [Required] public long FactorId { get; set; } @@ -59,10 +71,10 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService ) { var challenge = await db.AuthChallenges.FindAsync(id); - if (challenge is null) return new NotFoundResult(); + if (challenge is null) return new NotFoundObjectResult("Auth challenge was not found."); var factor = await db.AccountAuthFactors.FindAsync(request.FactorId); - if (factor is null) return new NotFoundResult(); + if (factor is null) return new NotFoundObjectResult("Auth factor was not found."); if (challenge.StepRemain == 0) return challenge; if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) @@ -84,67 +96,72 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService await db.SaveChangesAsync(); return challenge; } - - [HttpPost("challenge/{id}/grant")] - public async Task<ActionResult<SignedTokenPair>> GrantChallengeToken([FromRoute] Guid id) - { - var challenge = await db.AuthChallenges - .Include(e => e.Account) - .Where(e => e.Id == id) - .FirstOrDefaultAsync(); - if (challenge is null) return new NotFoundResult(); - if (challenge.StepRemain != 0) return new BadRequestResult(); - - var session = await db.AuthSessions - .Where(e => e.Challenge == challenge) - .FirstOrDefaultAsync(); - if (session is not null) return new BadRequestResult(); - session = new Session - { - LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), - ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), - Account = challenge.Account, - Challenge = challenge, - }; - - db.AuthSessions.Add(session); - await db.SaveChangesAsync(); - - return auth.CreateToken(session); - } - public class TokenExchangeRequest { public string GrantType { get; set; } = string.Empty; - public string RefreshToken { get; set; } = string.Empty; + public string? RefreshToken { get; set; } + public string? Code { get; set; } } - + [HttpPost("token")] public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request) { + Session? session; switch (request.GrantType) { + case "authorization_code": + var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty; + if (code == Guid.Empty) + return new BadRequestObjectResult("Invalid or missing authorization code."); + var challenge = await db.AuthChallenges + .Include(e => e.Account) + .Where(e => e.Id == code) + .FirstOrDefaultAsync(); + if (challenge is null) + return new NotFoundObjectResult("Authorization code not found or expired."); + if (challenge.StepRemain != 0) + return new BadRequestObjectResult("Challenge not yet completed."); + + session = await db.AuthSessions + .Where(e => e.Challenge == challenge) + .FirstOrDefaultAsync(); + if (session is not null) + return new BadRequestObjectResult("Session already exists for this challenge."); + + session = new Session + { + LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), + Account = challenge.Account, + Challenge = challenge, + }; + + db.AuthSessions.Add(session); + await db.SaveChangesAsync(); + + return auth.CreateToken(session); case "refresh_token": var handler = new JwtSecurityTokenHandler(); var token = handler.ReadJwtToken(request.RefreshToken); var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value; if (!Guid.TryParse(sessionIdClaim, out var sessionId)) - return new UnauthorizedResult(); + return new UnauthorizedObjectResult("Invalid or missing session_id claim in refresh token."); + + session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); + if (session is null) + return new NotFoundObjectResult("Session not found or expired."); - var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); - if (session is null) return new NotFoundResult(); - session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); await db.SaveChangesAsync(); return auth.CreateToken(session); default: - return new BadRequestResult(); + return new BadRequestObjectResult("Unsupported grant type."); } } - + [Authorize] [HttpGet("test")] public async Task<ActionResult> Test() diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index 2a5f921..c7023b3 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -1,6 +1,8 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; +using Casbin; +using Microsoft.AspNetCore.Authorization; using Microsoft.IdentityModel.Tokens; using NodaTime; @@ -13,8 +15,68 @@ public class SignedTokenPair public Instant ExpiredAt { get; set; } } -public class AuthService(AppDatabase db, IConfiguration config) +public class AuthService(AppDatabase db, IConfiguration config, IEnforcer enforcer) { + public async Task<bool> AssignRoleToUserAsync(string user, string role, string domain = "global") + { + var added = await enforcer.AddGroupingPolicyAsync(user, role, domain); + if (added) await enforcer.SavePolicyAsync(); + return added; + } + + public async Task<bool> AddPermissionToUserAsync(string user, string domain, string obj, string act) + { + var added = await enforcer.AddPolicyAsync(user, domain, obj, act); + if (added) await enforcer.SavePolicyAsync(); + return added; + } + + public async Task<bool> RemovePermissionFromUserAsync(string user, string domain, string obj, string act) + { + var removed = await enforcer.RemovePolicyAsync(user, domain, obj, act); + if (removed) await enforcer.SavePolicyAsync(); + return removed; + } + + public async Task<bool> CreateRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions) + { + bool anyAdded = false; + foreach (var (obj, act) in permissions) + { + var added = await enforcer.AddPolicyAsync(role, domain, obj, act); + if (added) anyAdded = true; + } + + if (anyAdded) await enforcer.SavePolicyAsync(); + return anyAdded; + } + + public async Task<bool> AddPermissionsToRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions) + { + bool anyAdded = false; + foreach (var (obj, act) in permissions) + { + var added = await enforcer.AddPolicyAsync(role, domain, obj, act); + if (added) anyAdded = true; + } + + if (anyAdded) await enforcer.SavePolicyAsync(); + return anyAdded; + } + + public async Task<bool> RemovePermissionsFromRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions) + { + bool anyRemoved = false; + foreach (var (obj, act) in permissions) + { + var removed = await enforcer.RemovePolicyAsync(role, domain, obj, act); + if (removed) anyRemoved = true; + } + + if (anyRemoved) await enforcer.SavePolicyAsync(); + return anyRemoved; + } + public SignedTokenPair CreateToken(Session session) { var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!); @@ -23,29 +85,29 @@ public class AuthService(AppDatabase db, IConfiguration config) var key = new RsaSecurityKey(rsa); var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + var claims = new List<Claim> + { + new("user_id", session.Account.Id.ToString()), + new("session_id", session.Id.ToString()) + }; - var accessTokenClaims = new JwtSecurityToken( - issuer: "solar-network", - audience: string.Join(',', session.Challenge.Audiences), - claims: new List<Claim> - { - new("user_id", session.Account.Id.ToString()), - new("session_id", session.Id.ToString()) - }, - expires: DateTime.Now.AddMinutes(30), - signingCredentials: creds - ); var refreshTokenClaims = new JwtSecurityToken( issuer: "solar-network", audience: string.Join(',', session.Challenge.Audiences), - claims: new List<Claim> - { - new("user_id", session.Account.Id.ToString()), - new("session_id", session.Id.ToString()) - }, + claims: claims, expires: DateTime.Now.AddDays(30), signingCredentials: creds ); + + session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c))); + if(session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1")); + var accessTokenClaims = new JwtSecurityToken( + issuer: "solar-network", + audience: string.Join(',', session.Challenge.Audiences), + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds + ); var handler = new JwtSecurityTokenHandler(); var accessToken = handler.WriteToken(accessTokenClaims); diff --git a/DysonNetwork.Sphere/Auth/CasbinRequirement.cs b/DysonNetwork.Sphere/Auth/CasbinRequirement.cs new file mode 100644 index 0000000..0e26bef --- /dev/null +++ b/DysonNetwork.Sphere/Auth/CasbinRequirement.cs @@ -0,0 +1,34 @@ +using Casbin; +using Microsoft.AspNetCore.Authorization; + +namespace DysonNetwork.Sphere.Auth; + +public class CasbinRequirement(string domain, string obj, string act) : IAuthorizationRequirement +{ + public string Domain { get; } = domain; + public string Object { get; } = obj; + public string Action { get; } = act; +} + +public class CasbinAuthorizationHandler(IEnforcer enforcer) + : AuthorizationHandler<CasbinRequirement> +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + CasbinRequirement requirement) + { + var userId = context.User.FindFirst("user_id")?.Value; + if (userId == null) return; + var isSuperuser = context.User.FindFirst("is_superuser")?.Value == "1"; + if (isSuperuser) userId = "super:" + userId; + + var allowed = await enforcer.EnforceAsync( + userId, + requirement.Domain, + requirement.Object, + requirement.Action + ); + + if (allowed) context.Succeed(requirement); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs index 3e06697..9ee4baf 100644 --- a/DysonNetwork.Sphere/Auth/Session.cs +++ b/DysonNetwork.Sphere/Auth/Session.cs @@ -22,8 +22,8 @@ public class Challenge : BaseModel public int StepRemain { get; set; } public int StepTotal { get; set; } [Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new(); - [Column(TypeName = "jsonb")] public List<string> Claims { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new(); + [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new(); [MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(256)] public string? DeviceId { get; set; } diff --git a/DysonNetwork.Sphere/Casbin.conf b/DysonNetwork.Sphere/Casbin.conf new file mode 100644 index 0000000..b3b09b6 --- /dev/null +++ b/DysonNetwork.Sphere/Casbin.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, dom, obj, act + +[policy_definition] +p = sub, dom, obj, act + +[role_definition] +g = _, _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub.StartsWith("super:") || (g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act) \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 13e758e..bacff2e 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -10,6 +10,8 @@ <ItemGroup> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> + <PackageReference Include="Casbin.NET" Version="2.12.0" /> + <PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" /> diff --git a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs similarity index 98% rename from DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs rename to DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs index 8a78ce7..2860f65 100644 --- a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs +++ b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs @@ -14,7 +14,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace DysonNetwork.Sphere.Migrations { [DbContext(typeof(AppDatabase))] - [Migration("20250409150800_AddAuthSession")] + [Migration("20250410150812_AddAuthSession")] partial class AddAuthSession { /// <inheritdoc /> @@ -177,11 +177,6 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("jsonb") .HasColumnName("blacklist_factors"); - b.Property<List<string>>("Claims") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("claims"); - b.Property<Instant>("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -209,6 +204,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(1024)") .HasColumnName("nonce"); + b.Property<List<string>>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + b.Property<int>("StepRemain") .HasColumnType("integer") .HasColumnName("step_remain"); diff --git a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs similarity index 98% rename from DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs rename to DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs index bcf4a2a..c7bba51 100644 --- a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs +++ b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs @@ -22,8 +22,8 @@ namespace DysonNetwork.Sphere.Migrations step_remain = table.Column<int>(type: "integer", nullable: false), step_total = table.Column<int>(type: "integer", nullable: false), blacklist_factors = table.Column<List<long>>(type: "jsonb", nullable: false), - claims = table.Column<List<string>>(type: "jsonb", nullable: false), audiences = table.Column<List<string>>(type: "jsonb", nullable: false), + scopes = table.Column<List<string>>(type: "jsonb", nullable: false), ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true), user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true), device_id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), diff --git a/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs new file mode 100644 index 0000000..44c0675 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs @@ -0,0 +1,365 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using DysonNetwork.Sphere; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20250410161354_AddSuperUser")] + partial class AddSuperUser + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<Instant>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Instant?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<bool>("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property<string>("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<Instant>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Instant?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<string>("Secret") + .HasColumnType("text") + .HasColumnName("secret"); + + b.Property<int>("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_account_auth_factors"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_auth_factors_account_id"); + + b.ToTable("account_auth_factors", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("content"); + + b.Property<Instant>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Instant?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<int>("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Instant?>("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_account_contacts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_contacts_account_id"); + + b.ToTable("account_contacts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<List<string>>("Audiences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("audiences"); + + b.Property<List<long>>("BlacklistFactors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("blacklist_factors"); + + b.Property<Instant>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Instant?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<string>("DeviceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("device_id"); + + b.Property<Instant?>("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property<string>("IpAddress") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ip_address"); + + b.Property<string>("Nonce") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nonce"); + + b.Property<List<string>>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.Property<int>("StepRemain") + .HasColumnType("integer") + .HasColumnName("step_remain"); + + b.Property<int>("StepTotal") + .HasColumnType("integer") + .HasColumnName("step_total"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<string>("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_challenges_account_id"); + + b.ToTable("auth_challenges", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<Guid>("ChallengeId") + .HasColumnType("uuid") + .HasColumnName("challenge_id"); + + b.Property<Instant>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Instant?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<Instant?>("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property<Instant?>("LastGrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_granted_at"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_sessions_account_id"); + + b.HasIndex("ChallengeId") + .HasDatabaseName("ix_auth_sessions_challenge_id"); + + b.ToTable("auth_sessions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("AuthFactors") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_auth_factors_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_contacts_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Challenges") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Sessions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge") + .WithMany() + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id"); + + b.Navigation("Account"); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => + { + b.Navigation("AuthFactors"); + + b.Navigation("Challenges"); + + b.Navigation("Contacts"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs new file mode 100644 index 0000000..11db0f8 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + /// <inheritdoc /> + public partial class AddSuperUser : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<bool>( + name: "is_superuser", + table: "accounts", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn<string>( + name: "language", + table: "accounts", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "is_superuser", + table: "accounts"); + + migrationBuilder.DropColumn( + name: "language", + table: "accounts"); + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 21df139..3f6c0fa 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -41,6 +41,16 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("deleted_at"); + b.Property<bool>("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property<string>("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + b.Property<string>("Name") .IsRequired() .HasMaxLength(256) @@ -174,11 +184,6 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("jsonb") .HasColumnName("blacklist_factors"); - b.Property<List<string>>("Claims") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("claims"); - b.Property<Instant>("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -206,6 +211,11 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(1024)") .HasColumnName("nonce"); + b.Property<List<string>>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + b.Property<int>("StepRemain") .HasColumnType("integer") .HasColumnName("step_remain"); diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index db654a9..f5ebea5 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -1,9 +1,12 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Casbin; +using Casbin.Persist.Adapter.EFCore; using DysonNetwork.Sphere; using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Auth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -25,6 +28,23 @@ builder.Services.AddControllers().AddJsonOptions(options => }); builder.Services.AddHttpContextAccessor(); +// Casbin permissions + +var casbinDbContext = new CasbinDbContext<int>( + new DbContextOptionsBuilder<CasbinDbContext<int>>() + .UseNpgsql(builder.Configuration.GetConnectionString("Guard")) + .Options +); +var casbinEfcore = new EFCoreAdapter<int>(casbinDbContext); +casbinDbContext.Database.EnsureCreated(); +var casbinEncofcer = new Enforcer("Casbin.conf", casbinEfcore); +casbinEncofcer.LoadPolicy(); + +builder.Services.AddSingleton<IEnforcer>(casbinEncofcer); +builder.Services.AddSingleton<IAuthorizationHandler, CasbinAuthorizationHandler>(); + +// Other pipelines + builder.Services.AddAuthorization(); builder.Services.AddAuthentication("Bearer").AddJwtBearer(options => { diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index b6cf0b0..307b405 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -7,7 +7,8 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres" + "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres", + "Guard": "Host=localhost;Port=5432;Database=dyson_network_casbin;Username=postgres;Password=postgres" }, "Authentication": { "Schemes": {