Auth DB schema migration, ef design, auth

This commit is contained in:
femsci 2023-10-01 09:41:10 +02:00
parent a0d5df1c52
commit 5e4bfaa95e
Signed by: femsci
GPG key ID: 08F7911F0E650C67
22 changed files with 1106 additions and 38 deletions

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
**/obj
**/bin
**.db
**.db-wal
**.db-shm

39
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,39 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Core Server",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Interlinked.User/bin/Debug/net7.0/Interlinked.User.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Interlinked.User",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
},
"pipeTransport": {
"pipeCwd": "${workspaceFolder}",
"pipeProgram": "/usr/bin/bash",
"pipeArgs": ["-c"],
"debuggerPath": "/usr/bin/netcoredbg"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Interlinked.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Interlinked.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Interlinked.sln"
],
"problemMatcher": "$msCompile"
}
]
}

View file

@ -14,4 +14,8 @@
<PackageReference Include="Syncfusion.Blazor.Maps" Version="23.1.38" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Interlinked.Shared\Interlinked.Shared.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,77 @@
@page "/login"
@using System.ComponentModel.DataAnnotations;
@using System.Net
@inject HttpClient req
@inject UserManager usr
@inject NavigationManager nav
<PageTitle>Login</PageTitle>
<h1>Log in</h1>
<EditForm class="form" Model="@Model" OnValidSubmit="@Submit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="email">E-mail</label>
<InputText @bind-Value="Model!.Email" id="email" />
</div>
<div class="form-group">
<label for="pwd">Password</label>
<InputText type="password" @bind-Value="Model!.Password" id="pwd" />
</div>
<button type="submit">Log in</button>
</EditForm>
@if (!string.IsNullOrWhiteSpace(ValidationMsg))
{
<p class="text-danger">@ValidationMsg</p>
}
@code {
public record LoginModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = default!;
[Required]
public string Password { get; set; } = default!;
};
public LoginModel Model { get; set; } = new();
public string ValidationMsg = string.Empty;
protected override void OnInitialized() => Model ??= new();
private async Task Submit()
{
var resp = await req.PostAsJsonAsync("/api/auth/login", Model);
if (resp.StatusCode == HttpStatusCode.OK)
{
Model = new();
ValidationMsg = "Successful login <3";
var user = await resp.Content.ReadFromJsonAsync<UserModel>();
if (user == null)
{
ValidationMsg = "Unknown authentication error";
this.StateHasChanged();
return;
}
usr.User = user;
this.StateHasChanged();
nav.NavigateTo("/");
return;
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
Model = new();
ValidationMsg = "Invalid username or password.";
this.StateHasChanged();
return;
}
Model = new();
ValidationMsg = "Authorization error...";
this.StateHasChanged();
}
}

View file

@ -0,0 +1,17 @@
@page "/logout"
@inject HttpClient req
@inject UserManager usr
@inject NavigationManager nav
@using System.Net
@code {
protected override async Task OnInitializedAsync()
{
var resp = await req.GetAsync("/api/auth/logout");
if (resp.StatusCode == HttpStatusCode.NoContent)
{
usr.User = null!;
}
nav.NavigateTo("/login");
}
}

View file

@ -0,0 +1,88 @@
@page "/register"
@using System.ComponentModel.DataAnnotations;
@using System.Net
@inject HttpClient req
@inject UserManager usr
@inject NavigationManager nav
<PageTitle>Register</PageTitle>
<h1>Register</h1>
<EditForm class="form" Model="@Model" OnValidSubmit="@Submit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="username">Username</label>
<InputText @bind-Value="Model!.Username" id="username" />
</div>
<div class="form-group">
<label for="name">Full name</label>
<InputText @bind-Value="Model!.Name" id="name" />
</div>
<div class="form-group">
<label for="email">E-mail</label>
<InputText @bind-Value="Model!.Email" id="email" />
</div>
<div class="form-group">
<label for="pwd">Password</label>
<InputText type="password" @bind-Value="Model!.Password" id="pwd" />
</div>
<button type="submit">Log in</button>
</EditForm>
@if (!string.IsNullOrWhiteSpace(ValidationMsg))
{
<p class="text-danger">@ValidationMsg</p>
}
@code {
public record LoginModel
{
[Required]
public string Username { get; set; } = default!;
public string? Name { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; } = default!;
[Required]
public string Password { get; set; } = default!;
};
public LoginModel Model { get; set; } = new();
public string ValidationMsg = string.Empty;
protected override void OnInitialized() => Model ??= new();
private async Task Submit()
{
var resp = await req.PostAsJsonAsync("/api/auth/login", Model);
if (resp.StatusCode == HttpStatusCode.OK)
{
Model = new();
ValidationMsg = "Successful login <3"; var user = await resp.Content.ReadFromJsonAsync<UserModel>();
if (user == null)
{
ValidationMsg = "Unknown authentication error";
this.StateHasChanged();
return;
}
usr.User = user;
this.StateHasChanged();
nav.NavigateTo("/");
return;
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
Model = new();
ValidationMsg = "Invalid username or password.";
this.StateHasChanged();
return;
}
Model = new();
ValidationMsg = "Authorization error...";
this.StateHasChanged();
}
}

View file

@ -4,6 +4,7 @@ using Interlinked.Core;
using Syncfusion.Blazor;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.Builder;
using Interlinked.Core.Services;
internal class Program
{
@ -21,6 +22,13 @@ internal class Program
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/octet-stream" });
});
builder.Services.AddSingleton<UserManager, UserManager>();
var app = builder.Build();
await app.Services.GetRequiredService<UserManager>().InitAsync();
await builder.Build().RunAsync();
}
}
}

View file

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Interlinked.Shared.Model;
namespace Interlinked.Core.Services;
public class UserManager
{
public UserManager(IServiceProvider serv)
{
_serv = serv;
}
private readonly IServiceProvider _serv;
public async Task InitAsync()
{
using var scope = _serv.CreateAsyncScope();
var resp = await scope.ServiceProvider.GetRequiredService<HttpClient>().GetAsync("/api/auth/userdata");
if (resp.StatusCode == HttpStatusCode.OK)
{
User = (await resp.Content.ReadFromJsonAsync<UserModel>())!;
}
}
public UserModel User { get; set; } = default!;
public bool IsAuthorized => User != null;
}

View file

@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inject UserManager usr
<div class="page">
<div class="sidebar">
@ -7,7 +8,15 @@
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
@if (!usr.IsAuthorized)
{
<a href="/login">Log in</a>
<a href="/register">Register</a>
}
else
{
<a href="/logout">Log out (@usr.User.Username)</a>
}
</div>
<article class="content px-4">

View file

@ -8,3 +8,5 @@
@using Microsoft.JSInterop
@using Interlinked.Core
@using Interlinked.Core.Shared
@using Interlinked.Core.Services
@using Interlinked.Shared.Model

View file

@ -10,7 +10,7 @@ public class AuthSession
public required byte[] Key { get; set; }
public required byte[] Secret { get; set; }
public long UserId { get; set; }
public IPAddress LastAddress { get; set; } = default!;
public IPAddress? LastAddress { get; set; } = default!;
public UserModel User { get; set; } = default!;
public override string ToString()

View file

@ -1,5 +1,6 @@
using System.Security.Cryptography;
using Interlinked.Shared.Model;
using Interlinked.User.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -10,20 +11,26 @@ namespace Interlinked.User.Controllers;
[Route("api/auth")]
public class AuthController : ControllerBase
{
public AuthController(StoreContext db)
public AuthController(StoreContext db, AuthManager auth)
{
_db = db;
_auth = auth;
}
private readonly StoreContext _db;
private readonly AuthManager _auth;
public record RegisterRequest(string email, string username, string? name, string password);
[AllowAnonymous]
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] string email, [FromBody] string username, [FromBody] string? name, [FromBody] string password)
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{
email = email.ToLower().Trim();
username = username.Trim();
var now = DateTime.UtcNow;
if (_db.Users.AsNoTracking().Any(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)))
string email = req.email.ToLower().Trim();
string username = req.username.Trim();
if (_db.Users.AsNoTracking().Any(u => u.Username == username))
{
return Conflict("Username taken.");
}
@ -35,46 +42,70 @@ public class AuthController : ControllerBase
byte[] salt = new byte[16];
RandomNumberGenerator.Fill(salt);
byte[] hash = UserModel.HashPassword(password, salt.ToArray());
byte[] hash = UserModel.HashPassword(req.password, salt.ToArray());
UserModel u = new()
{
Email = email,
Username = username,
Salt = salt,
Name = name,
Hash = hash
Name = req.name,
Hash = hash,
CreatedTimestamp = now
};
//throw new NotImplementedException();
await _db.Users.AddAsync(u);
await _db.SaveChangesAsync();
await _auth.CreateSession(u);
return Ok(u);
}
public record LoginRequest(string email, string password);
[AllowAnonymous]
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] string email, [FromBody] string password)
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
email = email.ToLower().Trim();
UserModel? u = _db.Users.AsNoTracking().FirstOrDefault(u => email.Equals(u.Email));
if ((await _auth.RetrieveCookie()) is not null)
{
return this.Conflict("Already authenticated...");
}
string email = req.email.ToLower().Trim();
UserModel? u = _db.Users.AsNoTracking().FirstOrDefault(u => email == u.Email);
if (u is null || !u.CheckHash(password))
if (u is null || !u.CheckHash(req.password))
{
return Unauthorized();
}
//Create auth session
throw new NotImplementedException();
await _auth.CreateSession(u);
return Ok(u);
}
[HttpGet("userdata")]
public async Task<IActionResult> GetUserData()
{
if (await _auth.RetrieveCookie() is not AuthSession sess)
{
return Unauthorized();
}
return Ok(await _db.Users.AsNoTracking().SingleAsync(s => s.Id == sess.UserId));
}
[Authorize]
[HttpGet("logout")]
public async Task<IActionResult> Logout()
{
throw new NotImplementedException();
if (await _auth.RetrieveCookie() is AuthSession sess)
{
await _auth.RevokeSession(sess);
return NoContent();
}
return Unauthorized();
}

View file

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Interlinked.User.Controllers;
[ApiController]
[Route("api/")]
public class SystemController : ControllerBase
{
[HttpGet("version")]
public async Task<IActionResult> Version()
{
return Ok("1.0E");
}
}

View file

@ -7,16 +7,23 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Interlinked.Shared\Interlinked.Shared.csproj" />
<ProjectReference Include="..\Interlinked.Core\Interlinked.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,222 @@
// <auto-generated />
using System;
using Interlinked.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Interlinked.User.Migrations
{
[DbContext(typeof(StoreContext))]
[Migration("20231001074047_Initial schema")]
partial class Initialschema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
modelBuilder.Entity("Interlinked.Shared.Model.AuthSession", b =>
{
b.Property<byte[]>("Key")
.HasColumnType("BLOB");
b.Property<string>("LastAddress")
.HasColumnType("TEXT");
b.Property<byte[]>("Secret")
.IsRequired()
.HasColumnType("BLOB");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.HasIndex("UserId");
b.ToTable("AuthSessions");
});
modelBuilder.Entity("Interlinked.Shared.Model.Project", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<long>("OwnerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Projects");
});
modelBuilder.Entity("Interlinked.Shared.Model.Tag", b =>
{
b.Property<string>("Identifier")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Identifier");
b.ToTable("Tags");
});
modelBuilder.Entity("Interlinked.Shared.Model.UserModel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("City")
.HasColumnType("TEXT");
b.Property<string>("CountryCode")
.IsRequired()
.HasMaxLength(2)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedTimestamp")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime?>("DayOfBirth")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Gender")
.HasColumnType("TEXT");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<byte[]>("Salt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("ProjectTag", b =>
{
b.Property<long>("ProjectsId")
.HasColumnType("INTEGER");
b.Property<string>("TagsIdentifier")
.HasColumnType("TEXT");
b.HasKey("ProjectsId", "TagsIdentifier");
b.HasIndex("TagsIdentifier");
b.ToTable("ProjectTag");
});
modelBuilder.Entity("TagUserModel", b =>
{
b.Property<string>("InterestsIdentifier")
.HasColumnType("TEXT");
b.Property<long>("UsersId")
.HasColumnType("INTEGER");
b.HasKey("InterestsIdentifier", "UsersId");
b.HasIndex("UsersId");
b.ToTable("TagUserModel");
});
modelBuilder.Entity("Interlinked.Shared.Model.AuthSession", b =>
{
b.HasOne("Interlinked.Shared.Model.UserModel", "User")
.WithMany("AuthSessions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Interlinked.Shared.Model.Project", b =>
{
b.HasOne("Interlinked.Shared.Model.UserModel", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("ProjectTag", b =>
{
b.HasOne("Interlinked.Shared.Model.Project", null)
.WithMany()
.HasForeignKey("ProjectsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Interlinked.Shared.Model.Tag", null)
.WithMany()
.HasForeignKey("TagsIdentifier")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TagUserModel", b =>
{
b.HasOne("Interlinked.Shared.Model.Tag", null)
.WithMany()
.HasForeignKey("InterestsIdentifier")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Interlinked.Shared.Model.UserModel", null)
.WithMany()
.HasForeignKey("UsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Interlinked.Shared.Model.UserModel", b =>
{
b.Navigation("AuthSessions");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,180 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Interlinked.User.Migrations
{
/// <inheritdoc />
public partial class Initialschema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Identifier = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Identifier);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Username = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false, collation: "NOCASE"),
Email = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 96, nullable: true),
Salt = table.Column<byte[]>(type: "BLOB", nullable: false),
Hash = table.Column<byte[]>(type: "BLOB", nullable: false),
City = table.Column<string>(type: "TEXT", nullable: true),
CountryCode = table.Column<string>(type: "TEXT", maxLength: 2, nullable: false),
Gender = table.Column<string>(type: "TEXT", nullable: true),
DayOfBirth = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedTimestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuthSessions",
columns: table => new
{
Key = table.Column<byte[]>(type: "BLOB", nullable: false),
Secret = table.Column<byte[]>(type: "BLOB", nullable: false),
UserId = table.Column<long>(type: "INTEGER", nullable: false),
LastAddress = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AuthSessions", x => x.Key);
table.ForeignKey(
name: "FK_AuthSessions_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
OwnerId = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
table.ForeignKey(
name: "FK_Projects_Users_OwnerId",
column: x => x.OwnerId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TagUserModel",
columns: table => new
{
InterestsIdentifier = table.Column<string>(type: "TEXT", nullable: false),
UsersId = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TagUserModel", x => new { x.InterestsIdentifier, x.UsersId });
table.ForeignKey(
name: "FK_TagUserModel_Tags_InterestsIdentifier",
column: x => x.InterestsIdentifier,
principalTable: "Tags",
principalColumn: "Identifier",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TagUserModel_Users_UsersId",
column: x => x.UsersId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProjectTag",
columns: table => new
{
ProjectsId = table.Column<long>(type: "INTEGER", nullable: false),
TagsIdentifier = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProjectTag", x => new { x.ProjectsId, x.TagsIdentifier });
table.ForeignKey(
name: "FK_ProjectTag_Projects_ProjectsId",
column: x => x.ProjectsId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProjectTag_Tags_TagsIdentifier",
column: x => x.TagsIdentifier,
principalTable: "Tags",
principalColumn: "Identifier",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AuthSessions_UserId",
table: "AuthSessions",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Projects_OwnerId",
table: "Projects",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_ProjectTag_TagsIdentifier",
table: "ProjectTag",
column: "TagsIdentifier");
migrationBuilder.CreateIndex(
name: "IX_TagUserModel_UsersId",
table: "TagUserModel",
column: "UsersId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuthSessions");
migrationBuilder.DropTable(
name: "ProjectTag");
migrationBuilder.DropTable(
name: "TagUserModel");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View file

@ -0,0 +1,219 @@
// <auto-generated />
using System;
using Interlinked.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Interlinked.User.Migrations
{
[DbContext(typeof(StoreContext))]
partial class StoreContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
modelBuilder.Entity("Interlinked.Shared.Model.AuthSession", b =>
{
b.Property<byte[]>("Key")
.HasColumnType("BLOB");
b.Property<string>("LastAddress")
.HasColumnType("TEXT");
b.Property<byte[]>("Secret")
.IsRequired()
.HasColumnType("BLOB");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.HasIndex("UserId");
b.ToTable("AuthSessions");
});
modelBuilder.Entity("Interlinked.Shared.Model.Project", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<long>("OwnerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Projects");
});
modelBuilder.Entity("Interlinked.Shared.Model.Tag", b =>
{
b.Property<string>("Identifier")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Identifier");
b.ToTable("Tags");
});
modelBuilder.Entity("Interlinked.Shared.Model.UserModel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("City")
.HasColumnType("TEXT");
b.Property<string>("CountryCode")
.IsRequired()
.HasMaxLength(2)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedTimestamp")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime?>("DayOfBirth")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Gender")
.HasColumnType("TEXT");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<byte[]>("Salt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("ProjectTag", b =>
{
b.Property<long>("ProjectsId")
.HasColumnType("INTEGER");
b.Property<string>("TagsIdentifier")
.HasColumnType("TEXT");
b.HasKey("ProjectsId", "TagsIdentifier");
b.HasIndex("TagsIdentifier");
b.ToTable("ProjectTag");
});
modelBuilder.Entity("TagUserModel", b =>
{
b.Property<string>("InterestsIdentifier")
.HasColumnType("TEXT");
b.Property<long>("UsersId")
.HasColumnType("INTEGER");
b.HasKey("InterestsIdentifier", "UsersId");
b.HasIndex("UsersId");
b.ToTable("TagUserModel");
});
modelBuilder.Entity("Interlinked.Shared.Model.AuthSession", b =>
{
b.HasOne("Interlinked.Shared.Model.UserModel", "User")
.WithMany("AuthSessions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Interlinked.Shared.Model.Project", b =>
{
b.HasOne("Interlinked.Shared.Model.UserModel", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("ProjectTag", b =>
{
b.HasOne("Interlinked.Shared.Model.Project", null)
.WithMany()
.HasForeignKey("ProjectsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Interlinked.Shared.Model.Tag", null)
.WithMany()
.HasForeignKey("TagsIdentifier")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TagUserModel", b =>
{
b.HasOne("Interlinked.Shared.Model.Tag", null)
.WithMany()
.HasForeignKey("InterestsIdentifier")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Interlinked.Shared.Model.UserModel", null)
.WithMany()
.HasForeignKey("UsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Interlinked.Shared.Model.UserModel", b =>
{
b.Navigation("AuthSessions");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Interlinked.Shared.Model;
using Microsoft.AspNetCore.Identity;
namespace Interlinked.User.Models;
public class UserDomain : IdentityUser<long>
{
[EmailAddress]
public required string Email { get; set; }
[MaxLength(96)]
public string? Name { get; set; }
[JsonIgnore]
public byte[] Salt { get; set; } = default!;
[JsonIgnore]
public byte[] Hash { get; set; } = default!;
public string? City { get; set; }
[StringLength(2)]
public string CountryCode { get; set; } = "XX";
public string? Gender { get; set; } = "cat";
public DateTime? DayOfBirth { get; set; }
public ICollection<Tag> Interests { get; set; } = default!;
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public DateTime CreatedTimestamp { get; set; }
}

View file

@ -1,13 +1,15 @@
using Interlinked.Shared.Model;
using Interlinked.User;
using Interlinked.User.Models;
using Interlinked.User.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<StoreContext>();
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddDefaultIdentity<UserModel>(o => o.SignIn.RequireConfirmedAccount = true)
builder.Services.AddDefaultIdentity<UserDomain>(o => o.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<StoreContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
@ -20,14 +22,34 @@ builder.Services.ConfigureApplicationCookie(options =>
options.AccessDeniedPath = "/auth/account/uwuless";
options.SlidingExpiration = true;
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthManager, AuthManager>();
var app = builder.Build();
app.UseHttpsRedirection();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<StoreContext>();
db.Database.Migrate();
}
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseHttpsRedirection();
}
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();

View file

@ -11,8 +11,8 @@ namespace Interlinked.User.Services;
public class AuthManager
{
public const string AUTH_COOKIE_NAME = "bOiKissEr_ᓚᘏᗢ_UwU";
public AuthManager(StoreContext db, HttpContext http, IMemoryCache cache)
public const string AUTH_COOKIE_NAME = "bOiKissEr_UwU";
public AuthManager(StoreContext db, IHttpContextAccessor http, IMemoryCache cache)
{
_db = db;
_http = http;
@ -22,7 +22,7 @@ public class AuthManager
private readonly IMemoryCache _cache;
private readonly StoreContext _db;
private readonly HttpContext _http;
private readonly IHttpContextAccessor _http;
public async Task<AuthSession> CreateSession(UserModel user)
{
@ -33,13 +33,14 @@ public class AuthManager
{
UserId = user.Id,
Secret = secret,
Key = key
Key = key,
LastAddress = _http.HttpContext!.Connection.RemoteIpAddress
};
await _db.AuthSessions.AddAsync(sess);
await _db.SaveChangesAsync();
_http.Response.Cookies.Append(AUTH_COOKIE_NAME, sess.ToString());
_http.HttpContext!.Response.Cookies.Append(AUTH_COOKIE_NAME, sess.ToString());
return sess;
}
@ -47,29 +48,39 @@ public class AuthManager
{
_db.AuthSessions.Remove(sess);
await _db.SaveChangesAsync();
_http.HttpContext!.Response.Cookies.Delete(AUTH_COOKIE_NAME);
}
public async Task<AuthSession?> RetrieveCookie()
{
if (!_http.Request.Cookies.TryGetValue(AUTH_COOKIE_NAME, out string? val) || val is null)
if (!_http.HttpContext!.Request.Cookies.TryGetValue(AUTH_COOKIE_NAME, out string? val) || val is null)
{
return null;
}
int idx = val.IndexOf('.');
string keyStr = val[0..(idx - 1)], hashStr = val;
byte[] key = Convert.FromBase64String(keyStr), hash = Convert.FromBase64String(hashStr);
try
{
var sess = await _db.AuthSessions.SingleOrDefaultAsync(s => s.Key.SequenceEqual(key));
int idx = val.IndexOf('.');
string keyStr = val[0..idx], hashStr = val[(idx + 1)..];
byte[] key = Convert.FromBase64String(keyStr), hash = Convert.FromBase64String(hashStr);
if (sess is null)
var sess = await _db.AuthSessions.SingleOrDefaultAsync(s => s.Key.SequenceEqual(key));
if (sess is null)
{
return null;
}
using var sha = SHA256.Create();
var trueHash = sha.ComputeHash(sess.Secret);
return trueHash.SequenceEqual(hash) ? sess : null;
}
catch (Exception)
{
return null;
}
using var sha = SHA256.Create();
var trueHash = sha.ComputeHash(sess.Secret);
return trueHash.SequenceEqual(hash) ? sess : null;
}
}

View file

@ -10,10 +10,17 @@ public class StoreContext : DbContext
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
protected override void OnConfiguring(DbContextOptionsBuilder o)
{
o.UseSqlite("Data Source=./store.db;Cache=Shared");
o.EnableDetailedErrors();
}
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<UserModel>().HasMany(u => u.AuthSessions).WithOne(a => a.User);
mb.Entity<UserModel>().HasMany(u => u.Interests).WithMany(t => t.Users);
mb.Entity<UserModel>().Property(p => p.Username).UseCollation("NOCASE");
mb.Entity<Project>().HasMany(p => p.Tags).WithMany(t => t.Projects);
}
}