22 Commits

Author SHA1 Message Date
3dd0460209 migrate to identity for authentication
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 12s
2026-03-21 15:38:05 -05:00
ef4f0c0159 fix: dto consistency 2026-03-21 14:13:19 -05:00
817e0b97e6 idk how yaml works apparently
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 8s
2026-03-21 00:23:30 -05:00
cdc8b5c7a4 export secret
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 3s
2026-03-21 00:22:31 -05:00
be731be724 yaml syuntax
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 8s
2026-03-21 00:20:25 -05:00
9fea2c5b7b properly set environment secret
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 7s
2026-03-21 00:16:52 -05:00
f7b537cbed i changed a filename oops
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 4s
2026-03-20 21:09:16 -05:00
109d5f88ea user dtos as strict object templates
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 6s
Build and Deploy Frontend / build-and-deploy (push) Successful in 7s
2026-03-20 21:08:32 -05:00
7229e369ae secretize jwt secret
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 9s
2026-03-20 20:52:59 -05:00
a343c2e246 test longer jwt secret
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 8s
2026-03-20 20:11:58 -05:00
ae888d2973 fix non-null register attributes
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 8s
2026-03-20 20:07:25 -05:00
21fad5f698 new migration
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 10s
2026-03-20 20:02:51 -05:00
ce701cd43d ( ˘︹˘ )
All checks were successful
Build and Deploy Frontend / build-and-deploy (push) Successful in 6s
2026-03-17 22:41:42 -05:00
74200c4575 add the login buttons
Some checks failed
Build and Deploy Frontend / build-and-deploy (push) Failing after 3s
2026-03-17 22:40:22 -05:00
8c86f5ddce literally every time
All checks were successful
Build and Deploy Frontend / build-and-deploy (push) Successful in 6s
2026-03-17 22:31:13 -05:00
661bb03d1d preliminary frontend for ther auth api
Some checks failed
Build and Deploy Frontend / build-and-deploy (push) Failing after 4s
Build and Deploy API / build-and-deploy (push) Successful in 12s
2026-03-17 22:30:59 -05:00
c19cd0c718 add auth migration
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 8s
2026-03-16 21:49:12 -05:00
d8f64754b4 add auth files
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 9s
2026-03-16 21:44:33 -05:00
96026d448f scaffold auth api infrastructure (doesn't work)
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 14s
2026-03-16 21:20:54 -05:00
3dfb1ee0a7 Merge branch 'main' of ssh://git.vxbard.net:2222/homeburger/agologum into feature/auth
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 5s
Build and Deploy Frontend / build-and-deploy (push) Successful in 4s
2026-03-15 13:33:06 -05:00
5fe1666163 init 2026-03-15 12:28:03 -05:00
59b5344377 Merge pull request 'feature/client-template' (#2) from feature/client-template into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 7s
Build and Deploy Frontend / build-and-deploy (push) Successful in 5s
Reviewed-on: #2
2026-03-14 23:10:46 -05:00
29 changed files with 1012 additions and 50 deletions

View File

@@ -39,5 +39,6 @@ jobs:
- name: Deploy container - name: Deploy container
run: | run: |
export POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} export POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
export JWT_SECRET=${{ secrets.JWT_SECRET }}
docker compose -f ./api/docker-compose.prod.yaml pull agologum-api docker compose -f ./api/docker-compose.prod.yaml pull agologum-api
docker compose -f ./api/docker-compose.prod.yaml up -d --force-recreate agologum-api docker compose -f ./api/docker-compose.prod.yaml up -d --force-recreate agologum-api

View File

@@ -0,0 +1,53 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace agologum_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260317024844_AddUserAuthFields")]
partial class AddUserAuthFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("agologumApi.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace agologum_api.Migrations
{
/// <inheritdoc />
public partial class AddUserAuthFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "Users");
}
}
}

View File

@@ -0,0 +1,61 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace agologum_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260321010235_CreateExtraUserInfo")]
partial class CreateExtraUserInfo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("agologumApi.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace agologum_api.Migrations
{
/// <inheritdoc />
public partial class CreateExtraUserInfo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "Users",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Role",
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Users");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}

View File

@@ -0,0 +1,84 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace agologum_api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260321203739_UseIdentityPlatform")]
partial class UseIdentityPlatform
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("agologumApi.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,221 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace agologum_api.Migrations
{
/// <inheritdoc />
public partial class UseIdentityPlatform : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Name",
table: "Users");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
migrationBuilder.AlterColumn<string>(
name: "PasswordHash",
table: "Users",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Email",
table: "Users",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Id",
table: "Users",
type: "text",
nullable: false,
oldClrType: typeof(int),
oldType: "integer")
.OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.AddColumn<int>(
name: "AccessFailedCount",
table: "Users",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "ConcurrencyStamp",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "EmailConfirmed",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LockoutEnabled",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LockoutEnd",
table: "Users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NormalizedEmail",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NormalizedUserName",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PhoneNumber",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "PhoneNumberConfirmed",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SecurityStamp",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TwoFactorEnabled",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "UserName",
table: "Users",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccessFailedCount",
table: "Users");
migrationBuilder.DropColumn(
name: "ConcurrencyStamp",
table: "Users");
migrationBuilder.DropColumn(
name: "EmailConfirmed",
table: "Users");
migrationBuilder.DropColumn(
name: "LockoutEnabled",
table: "Users");
migrationBuilder.DropColumn(
name: "LockoutEnd",
table: "Users");
migrationBuilder.DropColumn(
name: "NormalizedEmail",
table: "Users");
migrationBuilder.DropColumn(
name: "NormalizedUserName",
table: "Users");
migrationBuilder.DropColumn(
name: "PhoneNumber",
table: "Users");
migrationBuilder.DropColumn(
name: "PhoneNumberConfirmed",
table: "Users");
migrationBuilder.DropColumn(
name: "SecurityStamp",
table: "Users");
migrationBuilder.DropColumn(
name: "TwoFactorEnabled",
table: "Users");
migrationBuilder.DropColumn(
name: "UserName",
table: "Users");
migrationBuilder.AlterColumn<string>(
name: "PasswordHash",
table: "Users",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Email",
table: "Users",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "Id",
table: "Users",
type: "integer",
nullable: false,
oldClrType: typeof(string),
oldType: "text")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.AddColumn<string>(
name: "Name",
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Role",
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -22,18 +23,52 @@ namespace agologum_api.Migrations
modelBuilder.Entity("agologumApi.Models.User", b => modelBuilder.Entity("agologumApi.Models.User", b =>
{ {
b.Property<int>("Id") b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Name") b.Property<int>("AccessFailedCount")
.IsRequired() .HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");

View File

@@ -1,16 +1,49 @@
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using System.Text;
using agologumApi.Models;
using agologumApi.Services; using agologumApi.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var key = builder.Configuration["Jwt:Key"];
if(key == null) return;
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddControllers(); builder.Services.AddControllers();
// services
builder.Services.AddScoped<UserService>(); builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<JwtService>();
// configuration for jwt authentication
builder.Services.AddIdentity<User, IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "agologum",
ValidAudience = "agologum",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key))
};
});
builder.Services.AddAuthorization();
// configuration for behind my nginx proxy // configuration for behind my nginx proxy
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -41,10 +74,17 @@ builder.Services.AddCors(options =>
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// https://www.reddit.com/r/dotnet/comments/1h7vzbs/how_do_you_guys_handle_authorization_on_a_web_api/
// add authorization here
// controllers will have endpoints based on authorization
// frontend is a different story
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseCors("dev"); app.UseCors("dev");
app.UseAuthentication();
app.UseAuthorization();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsEnvironment("Development")) { if (app.Environment.IsEnvironment("Development")) {

View File

@@ -8,6 +8,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">

View File

@@ -9,5 +9,10 @@
"DefaultConnection": "Host=agologum-net;Port=5432;Database=agologum;Username=agologum;Password=${POSTGRES_PASSWORD}" "DefaultConnection": "Host=agologum-net;Port=5432;Database=agologum;Username=agologum;Password=${POSTGRES_PASSWORD}"
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"https_port": 443 "https_port": 443,
"Jwt": {
"Key": "",
"Issuer": "agologum-api",
"Audience": "agologum-users"
}
} }

View File

@@ -8,6 +8,7 @@ services:
restart: always restart: always
environment: environment:
ConnectionStrings__DefaultConnection: Host=agologum-db;Port=5432;Database=agologum;Username=agologum;Password=${POSTGRES_PASSWORD} ConnectionStrings__DefaultConnection: Host=agologum-db;Port=5432;Database=agologum;Username=agologum;Password=${POSTGRES_PASSWORD}
Jwt__Key: ${JWT_SECRET} # must export the secret as a variable in the ci script
ports: ports:
- "5000:5000" - "5000:5000"
networks: networks:

View File

@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using agologumApi.Models;
using agologumApi.Services;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase {
// identity things
private readonly UserManager<User> userManager_;
private readonly SignInManager<User> signInManager_;
private readonly UserService users_;
private readonly JwtService jwt_;
public AuthController(UserManager<User> userManager, SignInManager<User> signInManager, UserService users, JwtService jwt) {
userManager_ = userManager;
signInManager_ = signInManager;
users_ = users;
jwt_ = jwt;
}
[HttpPost("register")]
public async Task<ActionResult> Register(RegisterDto dto) {
var user = new User {
UserName = dto.UserName,
Email = dto.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password), // TODO: secondary hashing stage in client
CreatedAt = DateTime.UtcNow // yeah why not utc
};
var newUser = await users_.Create(user);
return CreatedAtAction(
nameof(Register),
new { id = newUser.Id },
user
);
}
[HttpPost("login")]
public async Task<ActionResult> Login(LoginDto dto)
{
var user = await users_.Get(dto.UserName);
if (user == null) return Unauthorized();
var result = await signInManager_.CheckPasswordSignInAsync(user, dto.Password, false);
if(!result.Succeeded) return Unauthorized();
var token = jwt_.GenerateJwt(user);
return Ok(new { token });
}
[Authorize] // authorize is handled by middleware
[HttpPost("logout")]
public ActionResult Logout() {
// dummy endpoint
// logout happens upon client-side jwt removal
return Ok();
}
// TODO
// refresh tokens
// email verification
// password reset
}

View File

@@ -1,28 +1,30 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using agologumApi.Models; using agologumApi.Models;
using agologumApi.Services; using agologumApi.Services;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class UsersController : ControllerBase public class UsersController : ControllerBase {
{
private readonly UserService service_; private readonly UserService service_;
public UsersController(UserService service) public UsersController(UserService service) {
{
service_ = service; service_ = service;
} }
[AllowAnonymous] // accessible if not authorized
[HttpGet] [HttpGet]
public async Task<ActionResult<List<User>>> getUsers() public async Task<ActionResult<List<User>>> getUsers() {
{
return Ok(await service_.GetAll()); return Ok(await service_.GetAll());
} }
[AllowAnonymous]
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
public async Task<ActionResult<User>> getUser(int id) public async Task<ActionResult<User>> getUser(int id) {
{
var user = await service_.Get(id); var user = await service_.Get(id);
if (user == null) return NotFound(); if (user == null) return NotFound();
@@ -30,9 +32,10 @@ public class UsersController : ControllerBase
return Ok(user); return Ok(user);
} }
[Authorize] // testing the authorization
[HttpPost] [HttpPost]
public async Task<ActionResult<User>> createUser(User user) public async Task<ActionResult<User>> createUser(User user) {
{
var created = await service_.Create(user); var created = await service_.Create(user);
return CreatedAtAction( return CreatedAtAction(
@@ -42,9 +45,10 @@ public class UsersController : ControllerBase
); );
} }
[Authorize]
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<ActionResult<User>> updateUser(int id, User user) public async Task<ActionResult<User>> updateUser(int id, User user) {
{
var updated = await service_.Update(user); var updated = await service_.Update(user);
if (updated == null) return NotFound(); if (updated == null) return NotFound();
@@ -52,9 +56,10 @@ public class UsersController : ControllerBase
return Ok(updated); return Ok(updated);
} }
[Authorize]
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<ActionResult> deleteUser(int id) public async Task<ActionResult> deleteUser(int id) {
{
var success = await service_.Delete(id); var success = await service_.Delete(id);
if (!success) return NotFound(); if (!success) return NotFound();

15
api/src/Models/Dto.cs Normal file
View File

@@ -0,0 +1,15 @@
public class RegisterDto {
public string UserName { get; set; } = "";
public string Email { get; set; } = "";
public string Password { get; set; } = "";
}
public class LoginDto {
public string UserName { get; set; } = "";
public string Password { get; set; } = "";
}

View File

@@ -1,10 +1,32 @@
using Microsoft.AspNetCore.Identity;
namespace agologumApi.Models; namespace agologumApi.Models;
public class User { public class User : IdentityUser {
public int Id { get; set; } public DateTime CreatedAt { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
// properties inherited from IdentityUser:
/*
AccessFailedCount: Gets or sets the number of failed login attempts for the current user.
Claims: Navigation property for the claims this user possesses.
ConcurrencyStamp: A random value that must change whenever a user is persisted to the store
Email: Gets or sets the email address for this user.
EmailConfirmed: Gets or sets a flag indicating if a user has confirmed their email address.
Id: Gets or sets the primary key for this user.
LockoutEnabled: Gets or sets a flag indicating if the user could be locked out.
LockoutEnd: Gets or sets the date and time, in UTC, when any user lockout ends.
Logins: Navigation property for this users login accounts.
NormalizedEmail: Gets or sets the normalized email address for this user.
NormalizedUserName: Gets or sets the normalized user name for this user.
PasswordHash: Gets or sets a salted and hashed representation of the password for this user.
PhoneNumber: Gets or sets a telephone number for the user.
PhoneNumberConfirmed: Gets or sets a flag indicating if a user has confirmed their telephone address.
Roles: Navigation property for the roles this user belongs to.
SecurityStamp: A random value that must change whenever a users credentials change (password changed, login removed)
TwoFactorEnabled: Gets or sets a flag indicating if two factor authentication is enabled for this user.
UserName: Gets or sets the user name for this user.
https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.entityframeworkcore.identityuser?view=aspnetcore-1.1
*/
}; };

View File

@@ -0,0 +1,46 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using agologumApi.Models;
public class JwtService {
private readonly IConfiguration config_;
public JwtService(IConfiguration config) { // why the heck does c# not have initializer lists ?
config_ = config;
}
public string? GenerateJwt(User user) {
string? jwtKey = config_["Jwt:Key"];
if(jwtKey == null) return null;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
if(user.UserName == null) return null;
// not too sure
var claims = new[] {
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
};
var token = new JwtSecurityToken(
issuer: "agologum",
audience: "agologum",
claims: claims,
expires: DateTime.UtcNow.AddHours(2), // will add a refresher later
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -21,6 +21,10 @@ public class UserService {
return await db_.Users.FindAsync(id); return await db_.Users.FindAsync(id);
} }
public async Task<User?> Get(string username) {
return await db_.Users.FirstOrDefaultAsync(u => u.UserName == username);
}
public async Task<User> Create(User user) { public async Task<User> Create(User user) {
db_.Users.Add(user); db_.Users.Add(user);
await db_.SaveChangesAsync(); await db_.SaveChangesAsync();

49
client/src/api/AuthApi.ts Normal file
View File

@@ -0,0 +1,49 @@
// service to interact with the api/auth endpoints
// handles user registration, user logins, tokens, password reset, etc.
import api from "./axios.ts"
import type { User, RegisterDto, LoginDto } from "../models/User.ts";
const API_URL: string = "/auth";
export const register = async (user: RegisterDto) => {
try {
const response = await api.post(`${API_URL}/register`, user);
// TODO: if valid
return true;
// else return false
} catch (err) {
return false;
}
}
export const login = async (user: LoginDto ) => {
try {
const response = await api.post(`${API_URL}/login`, user);
const token = response.data.token;
localStorage.setItem("token", token);
return true;
} catch (err) {
return false;
}
}
export const logout = () => {
localStorage.removeItem("token");
}
export const getToken = () => {
return localStorage.getItem("token");
}

View File

@@ -1,19 +1,12 @@
// services are kinda whatever, but in general its a good idea for all api calls to be within a service (at least thats how angular handles it) // services are kinda whatever, but in general its a good idea for all api calls to be within a service (at least thats how angular handles it)
// this user service will handle all to <-> from the server when handling user objects // this user service will handle all to <-> from the server when handling user objects
// should be injected with the http client (I think its axios ?)
import axios from "axios"; import api from "./axios.ts"
import type {AxiosResponse } from "axios";
import type { User } from "../models/User.ts"; import type { User } from "../models/User.ts";
const API_URL: string = "/users"; const API_URL: string = "/users";
const baseUrl: string = import.meta.env.DEV ? import.meta.env.VITE_DEV_API_URL : "https://app.vxbard.net/api" // TODO: overarching api service
const api = axios.create({
baseURL: baseUrl
});
export const getUsers = () => api.get<User[]>(`${API_URL}`); export const getUsers = () => api.get<User[]>(`${API_URL}`);
export const getUser = (id: number) => api.get<User>(`${API_URL}/${id}`); export const getUser = (id: number) => api.get<User>(`${API_URL}/${id}`);

24
client/src/api/axios.ts Normal file
View File

@@ -0,0 +1,24 @@
// http service hub
// handles interceptors and such
import axios from "axios";
const baseUrl: string = import.meta.env.DEV ? import.meta.env.VITE_DEV_API_URL : "https://app.vxbard.net/api"
const api = axios.create({
baseURL: baseUrl
});
api.interceptors.request.use(config => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -1,8 +1,21 @@
// models are the data objects stored in the database. models defined here must match models defined in api/models // models are the data objects stored in the database. models defined here must match models defined in api/models
// dtos here must match the the dtos in api/src/Modelts/Dto.cs in name (case insensitive) (types are intermediately serialized to strings)
export interface User { export interface User {
id: number; id: number;
name: string; name: string;
email: string email: string;
password: string;
}
export interface RegisterDto {
name: string;
email: string;
password: string;
}
export interface LoginDto {
name: string;
password: string;
} }

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { LoginDto } from "../models/User.ts";
import * as authApi from "../api/AuthApi";
const router = useRouter();
const user = reactive<LoginDto>({ // the template ensures type consistency
name: "",
password: "",
});
onMounted(() => {
});
async function login(): Promise<void> {
const success: boolean = await authApi.login(user);
if(success) {
router.push("/users"); // redirect
} else {
// prompt try again
}
}
</script>
<template>
<div>
<h2>Login</h2>
<form @submit.prevent="login">
<input v-model="user.name" placeholder="username" />
<input v-model="user.password" type="password" placeholder="password" />
<button type="submit">Submit</button>
</form>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { RegisterDto } from "../models/User.ts";
import * as authApi from "../api/AuthApi";
const router = useRouter();
const user = reactive<RegisterDto>({ // the template ensures type consistency
name: "",
email: "",
password: "",
});
onMounted(() => {
});
async function register(): Promise<void> {
const success: boolean = await authApi.register(user);
if(success) {
router.push("/login"); // redirect
} else {
// prompt try again
}
}
</script>
<template>
<div>
<h2>Register</h2>
<form @submit.prevent="register">
<input v-model="user.name" placeholder="username" />
<input v-model="user.email" placeholder="email" />
<input v-model="user.password" placeholder="password" />
<button type="submit">Submit</button>
</form>
</div>
</template>

View File

@@ -13,9 +13,10 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const user = ref<User>({ const user = ref<User>({
name: "",
id: 0, id: 0,
name: "",
email: "", email: "",
password: ""
}); });
const id: string | undefined = route.params.id as string | undefined const id: string | undefined = route.params.id as string | undefined

View File

@@ -2,14 +2,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue" import { onMounted } from "vue"
import { useRoute, useRouter } from "vue-router";
import { useUsersStore } from "../stores/UsersStore.ts" import { useUsersStore } from "../stores/UsersStore.ts"
import * as authApi from "../api/AuthApi";
const store = useUsersStore() const store = useUsersStore()
const router = useRouter();
onMounted(() => { onMounted(() => {
store.fetchUsers() store.fetchUsers()
}) })
function logout() {
authApi.logout();
router.push("/login");
}
</script> </script>
<template> <template>
@@ -31,5 +39,6 @@ onMounted(() => {
</td> </td>
</tr> </tr>
</table> </table>
<button @click="logout()">Logout</button>
</div> </div>
</template> </template>

View File

@@ -13,4 +13,12 @@
<router-link to="/users" custom v-slot="{ navigate }"> <router-link to="/users" custom v-slot="{ navigate }">
<button @click="navigate" role="link">Users</button> <button @click="navigate" role="link">Users</button>
</router-link> </router-link>
<router-link to="/register" custom v-slot="{ navigate }"> <!-- TODO: only if token == invalid -->
<button @click="navigate" role="link">Register</button>
</router-link>
<router-link to="/login" custom v-slot="{ navigate }"> <!-- TODO: only if token == invalid -->
<button @click="navigate" role="link">Login</button>
</router-link>
</template> </template>

View File

@@ -2,6 +2,8 @@
// the router creates front-end endpoints and serves pages to them // the router creates front-end endpoints and serves pages to them
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import LoginForm from "../pages/LoginForm.vue";
import RegisterForm from "../pages/RegisterForm.vue";
import UsersList from "../pages/UsersList.vue"; import UsersList from "../pages/UsersList.vue";
import UserForm from "../pages/UserForm.vue"; import UserForm from "../pages/UserForm.vue";
import index from "../pages/index.vue"; import index from "../pages/index.vue";
@@ -9,9 +11,11 @@ import index from "../pages/index.vue";
// link path to the page component // link path to the page component
const routes = [ const routes = [
{ path: "/", component: index }, { path: "/", component: index },
{ path: "/login", component: LoginForm },
{ path: "/register", component: RegisterForm },
{ path: "/users", component: UsersList }, { path: "/users", component: UsersList },
{ path: "/user/new", component: UserForm }, { path: "/user/new", component: UserForm, meta: { requiresAuth: true } },
{ path: "/user/:id", component: UserForm } { path: "/user/:id", component: UserForm, meta: { requiresAuth: true } }
]; // I really like this ]; // I really like this
const router = createRouter({ const router = createRouter({
@@ -19,4 +23,27 @@ const router = createRouter({
routes: routes, routes: routes,
}); });
// intercept before routing
router.beforeEach((to, from, next) => {
const token = localStorage.getItem("token");
if(to.meta.requiresAuth && !token) { // if the page requires use to be signed in, they must have at least a token set
next("/login");
} else {
next();
}
// TODO: if they have a token, but invalid, it will still send them to the page (the api will catch non-authorized though)
// maybe have a "validate token" from the api and refresh it if valid
/*
} else {
bool authorizedUser = authApi.refreshToken(token);
if(authorizedUser) {
next();
} else {
next("/login");
}
}
*/
});
export default router; export default router;

View File

@@ -6,7 +6,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { User } from "../models/User.ts"; import type { User } from "../models/User.ts";
import * as api from "../api/UsersApi"; import * as usersApi from "../api/UsersApi";
interface UserState { interface UserState {
users: User[]; users: User[];
@@ -23,24 +23,24 @@ export const useUsersStore = defineStore("users", {
actions: { actions: {
async fetchUsers() { async fetchUsers() {
this.loading = true; this.loading = true;
const response = await api.getUsers(); const response = await usersApi.getUsers();
this.users = response.data; this.users = response.data;
this.loading = false; this.loading = false;
}, },
async addUser(user: User) { async addUser(user: User) {
const response = await api.createUser(user); const response = await usersApi.createUser(user);
this.users.push(response.data); this.users.push(response.data);
}, },
async updateUser(id: number, user: User) { async updateUser(id: number, user: User) {
await api.updateUser(id, user); await usersApi.updateUser(id, user);
const index = this.users.findIndex(i => i.id === id); const index = this.users.findIndex(i => i.id === id);
this.users[index] = user; this.users[index] = user;
}, },
async removeUser(id: number) { async removeUser(id: number) {
await api.deleteUser(id); await usersApi.deleteUser(id);
this.users = this.users.filter(i => i.id !== id); this.users = this.users.filter(i => i.id !== id);
} }
} }