From 71f05154af05910ba5b0c67d14dd87bc6c31f1fb Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 10 Apr 2025 01:03:02 +0800 Subject: [PATCH] :sparkles: Basic auth --- DysonNetwork.Sphere/.gitignore | 1 + DysonNetwork.Sphere/Account/Account.cs | 41 +- DysonNetwork.Sphere/Account/AccountService.cs | 20 + DysonNetwork.Sphere/AppDatabase.cs | 30 +- DysonNetwork.Sphere/Auth/AuthController.cs | 161 ++++++++ DysonNetwork.Sphere/Auth/AuthService.cs | 61 +++ DysonNetwork.Sphere/Auth/Session.cs | 39 ++ .../DysonNetwork.Sphere.csproj | 12 +- .../20250409150800_AddAuthSession.Designer.cs | 355 ++++++++++++++++++ .../20250409150800_AddAuthSession.cs | 103 +++++ .../Migrations/AppDatabaseModelSnapshot.cs | 165 ++++++++ DysonNetwork.Sphere/Program.cs | 63 +++- DysonNetwork.Sphere/appsettings.json | 15 + DysonNetwork.sln.DotSettings.user | 11 +- 14 files changed, 1040 insertions(+), 37 deletions(-) create mode 100644 DysonNetwork.Sphere/.gitignore create mode 100644 DysonNetwork.Sphere/Account/AccountService.cs create mode 100644 DysonNetwork.Sphere/Auth/AuthController.cs create mode 100644 DysonNetwork.Sphere/Auth/AuthService.cs create mode 100644 DysonNetwork.Sphere/Auth/Session.cs create mode 100644 DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs create mode 100644 DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs diff --git a/DysonNetwork.Sphere/.gitignore b/DysonNetwork.Sphere/.gitignore new file mode 100644 index 0000000..fa31d04 --- /dev/null +++ b/DysonNetwork.Sphere/.gitignore @@ -0,0 +1 @@ +Keys \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index 71623a0..b866aae 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -1,9 +1,6 @@ using System.ComponentModel.DataAnnotations; -using System.Text; using System.Text.Json.Serialization; using NodaTime; -using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Security; namespace DysonNetwork.Sphere.Account; @@ -12,9 +9,12 @@ 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; - + public ICollection Contacts { get; set; } = new List(); - public ICollection AuthFactors { get; set; } = new List(); + + [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); + [JsonIgnore] public ICollection Sessions { get; set; } = new List(); + [JsonIgnore] public ICollection Challenges { get; set; } = new List(); } public class AccountContact : BaseModel @@ -23,13 +23,15 @@ public class AccountContact : BaseModel public AccountContactType Type { get; set; } public Instant? VerifiedAt { get; set; } [MaxLength(1024)] public string Content { get; set; } = string.Empty; - + [JsonIgnore] public Account Account { get; set; } = null!; } public enum AccountContactType { - Email, PhoneNumber, Address + Email, + PhoneNumber, + Address } public class AccountAuthFactor : BaseModel @@ -37,25 +39,28 @@ public class AccountAuthFactor : BaseModel public long Id { get; set; } public AccountAuthFactorType Type { get; set; } public string? Secret { get; set; } = null; - + [JsonIgnore] public Account Account { get; set; } = null!; public AccountAuthFactor HashSecret(int cost = 12) { - if(Secret == null) return this; - - var passwordBytes = Encoding.UTF8.GetBytes(Secret); - var random = new SecureRandom(); - var salt = new byte[16]; - random.NextBytes(salt); - var hashed = BCrypt.Generate(passwordBytes, salt, cost); - Secret = Convert.ToBase64String(hashed); - + if (Secret == null) return this; + Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost); return this; } + + public bool VerifyPassword(string password) + { + if (Secret == null) + throw new InvalidOperationException("Auth factor with no secret cannot be verified with password."); + return BCrypt.Net.BCrypt.Verify(password, Secret); + } } public enum AccountAuthFactorType { - Password, EmailCode, InAppCode, TimedCode + Password, + EmailCode, + InAppCode, + TimedCode } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs new file mode 100644 index 0000000..583b6b2 --- /dev/null +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Sphere.Account; + +public class AccountService(AppDatabase db) +{ + public async Task LookupAccount(string probe) + { + var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); + if (account is not null) return account; + + var contact = await db.AccountContacts + .Where(c => c.Content == probe) + .Include(c => c.Account) + .FirstOrDefaultAsync(); + if (contact is not null) return contact.Account; + + return null; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 468c823..7a23503 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using NodaTime; +using Npgsql; namespace DysonNetwork.Sphere; @@ -11,11 +12,31 @@ public abstract class BaseModel public Instant? DeletedAt { get; set; } } -public class AppDatabase(DbContextOptions options) : DbContext(options) +public class AppDatabase( + DbContextOptions options, + IConfiguration configuration +) : DbContext(options) { public DbSet Accounts { get; set; } public DbSet AccountContacts { get; set; } public DbSet AccountAuthFactors { get; set; } + public DbSet AuthSessions { get; set; } + public DbSet AuthChallenges { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetConnectionString("App")); + dataSourceBuilder.EnableDynamicJson(); + dataSourceBuilder.UseNodaTime(); + var dataSource = dataSourceBuilder.Build(); + + optionsBuilder.UseNpgsql( + dataSource, + opt => opt.UseNodaTime() + ).UseSnakeCaseNamingConvention(); + + base.OnConfiguring(optionsBuilder); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -82,11 +103,6 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory .Build(); var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql( - configuration.GetConnectionString("App"), - o => o.UseNodaTime() - ).UseSnakeCaseNamingConvention(); - - return new AppDatabase(optionsBuilder.Options); + return new AppDatabase(optionsBuilder.Options, configuration); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs new file mode 100644 index 0000000..cf1a7e0 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/AuthController.cs @@ -0,0 +1,161 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Sphere.Account; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NodaTime; +using Microsoft.EntityFrameworkCore; +using System.IdentityModel.Tokens.Jwt; + +namespace DysonNetwork.Sphere.Auth; + +[ApiController] +[Route("/auth")] +public class AuthController(AppDatabase db, AccountService accounts, AuthService auth, IHttpContextAccessor httpContext) +{ + public class ChallengeRequest + { + [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(); + } + + [HttpPost("challenge")] + public async Task> StartChallenge([FromBody] ChallengeRequest request) + { + var account = await accounts.LookupAccount(request.Account); + if (account is null) return new NotFoundResult(); + + var ipAddress = httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.HttpContext?.Request.Headers.UserAgent.ToString(); + + var challenge = new Challenge + { + Account = account, + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), + StepTotal = 1, + Claims = request.Claims, + Audiences = request.Audiences, + IpAddress = ipAddress, + UserAgent = userAgent, + DeviceId = request.DeviceId, + }.Normalize(); + + await db.AuthChallenges.AddAsync(challenge); + await db.SaveChangesAsync(); + return challenge; + } + + public class PerformChallengeRequest + { + [Required] public long FactorId { get; set; } + [Required] public string Password { get; set; } = string.Empty; + } + + [HttpPatch("challenge/{id}")] + public async Task> DoChallenge( + [FromRoute] Guid id, + [FromBody] PerformChallengeRequest request + ) + { + var challenge = await db.AuthChallenges.FindAsync(id); + if (challenge is null) return new NotFoundResult(); + + var factor = await db.AccountAuthFactors.FindAsync(request.FactorId); + if (factor is null) return new NotFoundResult(); + + if (challenge.StepRemain == 0) return challenge; + if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) + return new BadRequestResult(); + + try + { + if (factor.VerifyPassword(request.Password)) + { + challenge.StepRemain--; + challenge.BlacklistFactors.Add(factor.Id); + } + } + catch + { + return new BadRequestResult(); + } + + 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; + } + + [HttpPost("token")] + public async Task> ExchangeToken([FromBody] TokenExchangeRequest request) + { + switch (request.GrantType) + { + 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(); + + 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(); + } + } + + [Authorize] + [HttpGet("test")] + public async Task Test() + { + var sessionIdClaim = httpContext.HttpContext?.User.FindFirst("session_id")?.Value; + if (!Guid.TryParse(sessionIdClaim, out var sessionId)) + return new UnauthorizedResult(); + + var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); + if (session is null) return new NotFoundResult(); + + return new OkObjectResult(session); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs new file mode 100644 index 0000000..2a5f921 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -0,0 +1,61 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Sphere.Auth; + +public class SignedTokenPair +{ + public string AccessToken { get; set; } = null!; + public string RefreshToken { get; set; } = null!; + public Instant ExpiredAt { get; set; } +} + +public class AuthService(AppDatabase db, IConfiguration config) +{ + public SignedTokenPair CreateToken(Session session) + { + var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!); + var rsa = RSA.Create(); + rsa.ImportFromPem(privateKeyPem); + var key = new RsaSecurityKey(rsa); + + var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + + 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()) + }, + expires: DateTime.Now.AddDays(30), + signingCredentials: creds + ); + + var handler = new JwtSecurityTokenHandler(); + var accessToken = handler.WriteToken(accessTokenClaims); + var refreshToken = handler.WriteToken(refreshTokenClaims); + + return new SignedTokenPair + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddMinutes(30)) + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs new file mode 100644 index 0000000..3e06697 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/Session.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using NodaTime; + +namespace DysonNetwork.Sphere.Auth; + +public class Session : BaseModel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Instant? LastGrantedAt { get; set; } + public Instant? ExpiredAt { get; set; } + + [JsonIgnore] public Account.Account Account { get; set; } = null!; + [JsonIgnore] public Challenge Challenge { get; set; } = null!; +} + +public class Challenge : BaseModel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Instant? ExpiredAt { get; set; } + 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(); + [MaxLength(128)] public string? IpAddress { get; set; } + [MaxLength(512)] public string? UserAgent { get; set; } + [MaxLength(256)] public string? DeviceId { get; set; } + [MaxLength(1024)] public string? Nonce { get; set; } + + [JsonIgnore] public Account.Account Account { get; set; } = null!; + + public Challenge Normalize() + { + if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; + return this; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 7e32a5c..902dded 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -1,20 +1,24 @@ - + net9.0 enable enable Linux - + cfdec342-d2f2-4a86-800b-93f0a0e4abde + - + - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs b/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs new file mode 100644 index 0000000..8a78ce7 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs @@ -0,0 +1,355 @@ +// +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("20250409150800_AddAuthSession")] + partial class AddAuthSession + { + /// + 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("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>("Claims") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("claims"); + + 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("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/20250409150800_AddAuthSession.cs b/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs new file mode 100644 index 0000000..bcf4a2a --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + /// + public partial class AddAuthSession : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "auth_challenges", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + expired_at = table.Column(type: "timestamp with time zone", nullable: true), + 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), + 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), + nonce = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + account_id = table.Column(type: "bigint", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_auth_challenges", x => x.id); + table.ForeignKey( + name: "fk_auth_challenges_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "auth_sessions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + last_granted_at = table.Column(type: "timestamp with time zone", nullable: true), + expired_at = table.Column(type: "timestamp with time zone", nullable: true), + account_id = table.Column(type: "bigint", nullable: false), + challenge_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_auth_sessions", x => x.id); + table.ForeignKey( + name: "fk_auth_sessions_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_auth_sessions_auth_challenges_challenge_id", + column: x => x.challenge_id, + principalTable: "auth_challenges", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_auth_challenges_account_id", + table: "auth_challenges", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_auth_sessions_account_id", + table: "auth_sessions", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_auth_sessions_challenge_id", + table: "auth_sessions", + column: "challenge_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "auth_sessions"); + + migrationBuilder.DropTable( + name: "auth_challenges"); + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 8154da2..21df139 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -1,4 +1,6 @@ // +using System; +using System.Collections.Generic; using DysonNetwork.Sphere; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -151,6 +153,132 @@ namespace DysonNetwork.Sphere.Migrations 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>("Claims") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("claims"); + + 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("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") @@ -175,11 +303,48 @@ namespace DysonNetwork.Sphere.Migrations 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/Program.cs b/DysonNetwork.Sphere/Program.cs index 4c090f3..db654a9 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -1,6 +1,12 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using DysonNetwork.Sphere; +using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Auth; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using NodaTime; using NodaTime.Serialization.SystemTextJson; @@ -9,13 +15,7 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddDbContext(opt => - opt.UseNpgsql( - builder.Configuration.GetConnectionString("App"), - o => o.UseNodaTime() - ).UseSnakeCaseNamingConvention() -); - +builder.Services.AddDbContext(); builder.Services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; @@ -23,6 +23,24 @@ builder.Services.AddControllers().AddJsonOptions(options => options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); }); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddAuthorization(); +builder.Services.AddAuthentication("Bearer").AddJwtBearer(options => +{ + var publicKey = File.ReadAllText(builder.Configuration["Jwt:PublicKeyPath"]!); + var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "solar-network", + IssuerSigningKey = new RsaSecurityKey(rsa) + }; +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -39,9 +57,35 @@ builder.Services.AddSwaggerGen(options => Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") } }); + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + [] + } + }); }); builder.Services.AddOpenApi(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); if (app.Environment.IsDevelopment()) app.MapOpenApi(); @@ -49,6 +93,11 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.UseSwagger(); app.UseSwaggerUI(); +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All +}); + using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index b886009..b6cf0b0 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -8,5 +8,20 @@ "AllowedHosts": "*", "ConnectionStrings": { "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres" + }, + "Authentication": { + "Schemes": { + "Bearer": { + "ValidAudiences": [ + "http://localhost:5071", + "https://localhost:7099" + ], + "ValidIssuer": "solar-network" + } + } + }, + "Jwt": { + "PublicKeyPath": "Keys/PublicKey.pem", + "PrivateKeyPath": "Keys/PrivateKey.pem" } } diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index c38d425..d0cfeca 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -1,4 +1,13 @@  + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file