Basic auth

This commit is contained in:
LittleSheep 2025-04-10 01:03:02 +08:00
parent e90357d153
commit 71f05154af
14 changed files with 1040 additions and 37 deletions

1
DysonNetwork.Sphere/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
Keys

View File

@ -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<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
}
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
}

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account;
public class AccountService(AppDatabase db)
{
public async Task<Account?> 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;
}
}

View File

@ -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<AppDatabase> options) : DbContext(options)
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<Account.Account> Accounts { get; set; }
public DbSet<Account.AccountContact> AccountContacts { get; set; }
public DbSet<Account.AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Auth.Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> 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<AppDatabase>
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"),
o => o.UseNodaTime()
).UseSnakeCaseNamingConvention();
return new AppDatabase(optionsBuilder.Options);
return new AppDatabase(optionsBuilder.Options, configuration);
}
}

View File

@ -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<string> Claims { get; set; } = new();
public List<string> Audiences { get; set; } = new();
}
[HttpPost("challenge")]
public async Task<ActionResult<Challenge>> 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<ActionResult<Challenge>> 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<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;
}
[HttpPost("token")]
public async Task<ActionResult<SignedTokenPair>> 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<ActionResult> 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);
}
}

View File

@ -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<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())
},
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))
};
}
}

View File

@ -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<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();
[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;
}
}

View File

@ -1,20 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<UserSecretsId>cfdec342-d2f2-4a86-800b-93f0a0e4abde</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="9.1.2" />
<PackageReference Include="Microsoft.Orleans.Server" Version="9.1.2" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />

View File

@ -0,0 +1,355 @@
// <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("20250409150800_AddAuthSession")]
partial class AddAuthSession
{
/// <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<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<List<string>>("Claims")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("claims");
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<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
}
}
}

View File

@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddAuthSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "auth_challenges",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
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),
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),
nonce = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
account_id = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(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<Guid>(type: "uuid", nullable: false),
last_granted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<long>(type: "bigint", nullable: false),
challenge_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "auth_sessions");
migrationBuilder.DropTable(
name: "auth_challenges");
}
}
}

View File

@ -1,4 +1,6 @@
// <auto-generated />
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<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<List<string>>("Claims")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("claims");
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<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")
@ -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
}

View File

@ -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<AppDatabase>(opt =>
opt.UseNpgsql(
builder.Configuration.GetConnectionString("App"),
o => o.UseNodaTime()
).UseSnakeCaseNamingConvention()
);
builder.Services.AddDbContext<AppDatabase>();
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<AccountService>();
builder.Services.AddScoped<AuthService>();
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<AppDatabase>();

View File

@ -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"
}
}

View File

@ -1,4 +1,13 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa0b45f29f34f594814a7b1fbc25fe5ef3c18257956ed4f4fbfa68717db58_003FDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4a28847852ee9ba45fd3107526c0a749a733bd4f4ebf33aa3c9a59737a3f758_003FEntityFrameworkServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnumerable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F832399abc13b45b6bdbabfa022e4a28487e00_003F7f_003F7aece4dd_003FEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASecuritySchemeType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F29898ce74e3763a786ac1bd9a6db2152e1af75769440b1e53b9cbdf1dda1bd99_003FSecuritySchemeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionContainerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0e30e11d8f5456cb7a11b21ebee6c5a35c00_003F60_003F78b485f5_003FServiceCollectionContainerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>