User management

This commit is contained in:
2026-03-03 23:15:04 +02:00
parent 667fa25525
commit 2d1923d68d
17 changed files with 1046 additions and 74 deletions

View File

@@ -4,12 +4,13 @@ using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public record AuthTokenRequest(string Email, string Password);
public record AuthTokenRequest(string? Username, string Password);
public class AuthUser
public class AuthAdminOptions
{
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
public class AuthOptions
@@ -18,28 +19,28 @@ public class AuthOptions
public string Audience { get; set; } = "klapi-ui";
public string SigningKey { get; set; } = string.Empty;
public List<string> AllowedOrigins { get; set; } = [];
public List<AuthUser> Users { get; set; } = [];
public AuthAdminOptions Admin { get; set; } = new();
}
public static class AuthEndpoints
{
public static void MapAuthEndpoints(WebApplication app)
{
app.MapPost("/auth/token", (
app.MapPost("/auth/token", async (
HttpContext httpContext,
IOptions<AuthOptions> authOptions,
UserService userService,
AuthTokenRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Results.BadRequest(new { Message = "Email and password are required." });
return Results.BadRequest(new { Message = "Username and password are required." });
}
var options = authOptions.Value;
var user = options.Users.FirstOrDefault(item =>
string.Equals(item.Email, request.Email.Trim(), StringComparison.OrdinalIgnoreCase));
var authenticatedUser = await userService.Authenticate(request.Username, request.Password);
if (user is null || !string.Equals(user.Password, request.Password, StringComparison.Ordinal))
if (authenticatedUser is null)
{
return Results.Unauthorized();
}
@@ -48,9 +49,11 @@ public static class AuthEndpoints
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Email),
new(JwtRegisteredClaimNames.Email, user.Email),
new(ClaimTypes.Name, user.Email),
new(JwtRegisteredClaimNames.Sub, authenticatedUser.Username),
new(ClaimTypes.Name, authenticatedUser.Username),
new("username", authenticatedUser.Username),
new("display_name", authenticatedUser.DisplayName),
new("is_admin", authenticatedUser.IsAdmin ? "true" : "false"),
new("scope", "openhours:write")
};
@@ -66,7 +69,9 @@ public static class AuthEndpoints
return Results.Ok(new
{
AccessToken = tokenValue,
Email = user.Email,
Username = authenticatedUser.Username,
DisplayName = authenticatedUser.DisplayName,
IsAdmin = authenticatedUser.IsAdmin,
TokenType = "Bearer",
ExpiresIn = 43200
});

View File

@@ -0,0 +1,186 @@
using Microsoft.Data.Sqlite;
public static class UserEndpoints
{
public static void MapUserEndpoints(WebApplication app)
{
app.MapGet("/users", async (HttpContext httpContext) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var users = await userService.GetUsers();
await httpContext.Response.WriteAsJsonAsync(users);
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("GetUsers");
app.MapPost("/users", async (HttpContext httpContext) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserCreateRequest>();
if (request is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.DisplayName))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Username, password and display name are required."
});
return;
}
try
{
var createdUser = await userService.CreateUser(request);
httpContext.Response.StatusCode = StatusCodes.Status201Created;
httpContext.Response.Headers.Location = $"/users/{createdUser.Username}";
await httpContext.Response.WriteAsJsonAsync(createdUser);
}
catch (SqliteException)
{
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User with the same username already exists."
});
}
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("CreateUser");
app.MapPut("/users/{username}", async (HttpContext httpContext, string username) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserUpdateRequest>();
if (request is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(request.DisplayName))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Display name is required."
});
return;
}
var normalizedTargetUsername = username.Trim().ToLowerInvariant();
var existingUser = await userService.GetUser(normalizedTargetUsername);
if (existingUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
var adminCount = await userService.GetAdminCount();
if (existingUser.IsAdmin && !request.IsAdmin && adminCount <= 1)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Cannot remove admin role from the last admin user."
});
return;
}
var updatedUser = await userService.UpdateUser(username, request);
if (updatedUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(updatedUser);
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("UpdateUser");
app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var currentUsername = httpContext.User.Identity?.Name?.Trim().ToLowerInvariant() ?? string.Empty;
var normalizedTargetUsername = username.Trim().ToLowerInvariant();
var existingUser = await userService.GetUser(normalizedTargetUsername);
if (existingUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
if (currentUsername == normalizedTargetUsername)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "You cannot delete your own user account."
});
return;
}
var adminCount = await userService.GetAdminCount();
if (existingUser.IsAdmin && adminCount <= 1)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Cannot delete the last admin user."
});
return;
}
var deleted = await userService.DeleteUser(username);
if (!deleted)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("DeleteUser");
}
}

49
api/App/Models/AppUser.cs Normal file
View File

@@ -0,0 +1,49 @@
public class AppUser
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserView
{
public string Username { get; set; } = string.Empty;
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserCreateRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserUpdateRequest
{
public string? Password { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}

View File

@@ -30,15 +30,18 @@ public class Program
throw new InvalidOperationException("Auth:SigningKey must be at least 32 characters long.");
}
if (authOptions.Users.Count == 0)
if (string.IsNullOrWhiteSpace(authOptions.Admin.Username)
|| string.IsNullOrWhiteSpace(authOptions.Admin.Password)
|| string.IsNullOrWhiteSpace(authOptions.Admin.DisplayName))
{
throw new InvalidOperationException("At least one user must be configured under Auth:Users.");
throw new InvalidOperationException("Auth:Admin username, password and display name must be configured.");
}
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("PublicReadCors", policy =>
@@ -82,6 +85,12 @@ public class Program
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "openhours:write");
});
options.AddPolicy("AdminOnly", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("is_admin", "true");
});
});
builder.Services.AddOpenApi();
@@ -177,9 +186,20 @@ public class Program
ON LokOpenHours(isActive)
WHERE isActive = 1;";
command.ExecuteNonQuery();
command.CommandText = @"
CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Username
ON Users(username);";
command.ExecuteNonQuery();
}
}
using (var scope = app.Services.CreateScope())
{
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
userService.EnsureAdminUser(authOptions.Admin).GetAwaiter().GetResult();
}
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
@@ -198,6 +218,7 @@ public class Program
SystemEndpoints.MapSystemEndpoints(app);
AuthEndpoints.MapAuthEndpoints(app);
LokEndpoints.MapLokEndpoints(app);
UserEndpoints.MapUserEndpoints(app);
app.Run();
}

View File

@@ -0,0 +1,277 @@
using System.Data;
using Microsoft.Data.Sqlite;
public class UserService
{
private readonly SqliteConnection _connection;
public UserService(SqliteConnection connection)
{
_connection = connection;
}
public async Task EnsureAdminUser(AuthAdminOptions admin)
{
await EnsureOpenConnection();
var now = DateTime.UtcNow;
await using var command = _connection.CreateCommand();
command.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName)
ON CONFLICT(username) DO UPDATE SET
password = excluded.password,
lastUpdated = excluded.lastUpdated,
isAdmin = 1,
displayName = excluded.displayName;";
command.Parameters.AddWithValue("@username", admin.Username.Trim().ToLowerInvariant());
command.Parameters.AddWithValue("@password", admin.Password);
command.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", 1);
command.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim());
await command.ExecuteNonQueryAsync();
}
public async Task<AppUser?> Authenticate(string username, string password)
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName
FROM Users
WHERE username = @username
LIMIT 1;";
command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant());
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
var user = ReadUser(reader);
if (!string.Equals(user.Password, password, StringComparison.Ordinal))
{
return null;
}
return user;
}
public async Task<List<AppUserView>> GetUsers()
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT username, added, lastUpdated, isAdmin, displayName
FROM Users
ORDER BY username ASC;";
await using var reader = await command.ExecuteReaderAsync();
var users = new List<AppUserView>();
while (await reader.ReadAsync())
{
users.Add(new AppUserView
{
Username = reader["username"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()),
IsAdmin = ParseBoolean(reader["isAdmin"]),
DisplayName = reader["displayName"]?.ToString() ?? string.Empty
});
}
return users;
}
public async Task<AppUserView> CreateUser(AppUserCreateRequest request)
{
await EnsureOpenConnection();
var now = DateTime.UtcNow;
var normalizedUsername = request.Username.Trim().ToLowerInvariant();
await using var command = _connection.CreateCommand();
command.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName);";
command.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", request.Password);
command.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0);
command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
await command.ExecuteNonQueryAsync();
return new AppUserView
{
Username = normalizedUsername,
Added = now,
LastUpdated = now,
IsAdmin = request.IsAdmin,
DisplayName = request.DisplayName.Trim()
};
}
public async Task<AppUserView?> UpdateUser(string username, AppUserUpdateRequest request)
{
await EnsureOpenConnection();
var normalizedUsername = username.Trim().ToLowerInvariant();
var now = DateTime.UtcNow;
var currentUser = await GetUserByUsername(normalizedUsername);
if (currentUser is null)
{
return null;
}
await using var command = _connection.CreateCommand();
command.CommandText = @"
UPDATE Users
SET password = @password,
lastUpdated = @lastUpdated,
isAdmin = @isAdmin,
displayName = @displayName
WHERE username = @username;";
command.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", string.IsNullOrWhiteSpace(request.Password) ? currentUser.Password : request.Password);
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0);
command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows == 0)
{
return null;
}
return new AppUserView
{
Username = normalizedUsername,
Added = currentUser.Added,
LastUpdated = now,
IsAdmin = request.IsAdmin,
DisplayName = request.DisplayName.Trim()
};
}
public async Task<bool> DeleteUser(string username)
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM Users WHERE username = @username;";
command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant());
var affectedRows = await command.ExecuteNonQueryAsync();
return affectedRows > 0;
}
public async Task<int> GetAdminCount()
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Users WHERE isAdmin = 1;";
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
public async Task<AppUserView?> GetUser(string username)
{
await EnsureOpenConnection();
var user = await GetUserByUsername(username.Trim().ToLowerInvariant());
if (user is null)
{
return null;
}
return new AppUserView
{
Username = user.Username,
Added = user.Added,
LastUpdated = user.LastUpdated,
IsAdmin = user.IsAdmin,
DisplayName = user.DisplayName
};
}
private async Task<AppUser?> GetUserByUsername(string username)
{
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName
FROM Users
WHERE username = @username
LIMIT 1;";
command.Parameters.AddWithValue("@username", username);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
return ReadUser(reader);
}
private static AppUser ReadUser(SqliteDataReader reader)
{
return new AppUser
{
Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]),
Username = reader["username"]?.ToString() ?? string.Empty,
Password = reader["password"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()),
IsAdmin = ParseBoolean(reader["isAdmin"]),
DisplayName = reader["displayName"]?.ToString() ?? string.Empty
};
}
private static DateTime ParseDate(string? value)
{
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
{
return parsed;
}
return DateTime.MinValue;
}
private static bool ParseBoolean(object? value)
{
return value switch
{
bool boolValue => boolValue,
long longValue => longValue == 1,
int intValue => intValue == 1,
string stringValue when int.TryParse(stringValue, out var parsedInt) => parsedInt == 1,
string stringValue when bool.TryParse(stringValue, out var parsedBool) => parsedBool,
_ => false
};
}
private async Task EnsureOpenConnection()
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
}
}

View File

@@ -18,11 +18,10 @@
"http://localhost:4173",
"http://127.0.0.1:4173"
],
"Users": [
{
"Email": "admin@klapi.local",
"Password": "changeme"
}
]
"Admin": {
"Username": "admin",
"Password": "changeme",
"DisplayName": "Administrator"
}
}
}

View File

@@ -18,12 +18,11 @@
"http://localhost:4173",
"http://127.0.0.1:4173"
],
"Users": [
{
"Email": "admin@klapi.local",
"Password": "changeme"
}
]
"Admin": {
"Username": "admin",
"Password": "changeme",
"DisplayName": "Administrator"
}
},
"AllowedHosts": "*"
}