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 Contacts { get; set; } = new List(); 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 Claims { get; set; } = new(); public List Audiences { get; set; } = new(); + public List 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>> 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> 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> 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 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 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 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 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 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 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 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 + { + 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 - { - 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 - { - 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 +{ + 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 BlacklistFactors { get; set; } = new(); - [Column(TypeName = "jsonb")] public List Claims { get; set; } = new(); [Column(TypeName = "jsonb")] public List Audiences { get; set; } = new(); + [Column(TypeName = "jsonb")] public List 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 @@ + + 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 { /// @@ -177,11 +177,6 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("jsonb") .HasColumnName("blacklist_factors"); - b.Property>("Claims") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("claims"); - b.Property("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>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + b.Property("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(type: "integer", nullable: false), step_total = table.Column(type: "integer", nullable: false), blacklist_factors = table.Column>(type: "jsonb", nullable: false), - claims = table.Column>(type: "jsonb", nullable: false), audiences = table.Column>(type: "jsonb", nullable: false), + scopes = table.Column>(type: "jsonb", nullable: false), ip_address = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), user_agent = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), device_id = table.Column(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 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Secret") + .HasColumnType("text") + .HasColumnName("secret"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property>("Audiences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("audiences"); + + b.Property>("BlacklistFactors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("blacklist_factors"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeviceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("device_id"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("IpAddress") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ip_address"); + + b.Property("Nonce") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nonce"); + + b.Property>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.Property("StepRemain") + .HasColumnType("integer") + .HasColumnName("step_remain"); + + b.Property("StepTotal") + .HasColumnType("integer") + .HasColumnName("step_total"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("ChallengeId") + .HasColumnType("uuid") + .HasColumnName("challenge_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("LastGrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_granted_at"); + + b.Property("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 +{ + /// + public partial class AddSuperUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_superuser", + table: "accounts", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "language", + table: "accounts", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + } + + /// + 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("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -174,11 +184,6 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("jsonb") .HasColumnName("blacklist_factors"); - b.Property>("Claims") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("claims"); - b.Property("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>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + b.Property("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( + new DbContextOptionsBuilder>() + .UseNpgsql(builder.Configuration.GetConnectionString("Guard")) + .Options +); +var casbinEfcore = new EFCoreAdapter(casbinDbContext); +casbinDbContext.Database.EnsureCreated(); +var casbinEncofcer = new Enforcer("Casbin.conf", casbinEfcore); +casbinEncofcer.LoadPolicy(); + +builder.Services.AddSingleton(casbinEncofcer); +builder.Services.AddSingleton(); + +// 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": {