teacher-tools/api/source/api_modules/auth.d

279 lines
9.3 KiB
D
Raw Normal View History

2024-12-16 22:20:15 +00:00
module api_modules.auth;
import handy_httpd;
import handy_httpd.components.optional;
2025-01-22 17:55:14 +00:00
import handy_httpd.handlers.path_handler;
2024-12-16 22:20:15 +00:00
import slf4d;
2025-01-22 17:55:14 +00:00
import std.algorithm : map;
import std.array : array;
import std.json;
2025-01-23 17:10:32 +00:00
import ddbc;
2024-12-16 22:20:15 +00:00
import db;
import data_utils;
struct User {
const ulong id;
const string username;
const string passwordHash;
const ulong createdAt;
const bool isLocked;
const bool isAdmin;
2025-01-23 17:10:32 +00:00
static User parse(DataSetReader r) {
return User(
r.getUlong(1),
r.getString(2),
r.getString(3),
r.getUlong(4),
r.getBoolean(5),
r.getBoolean(6)
);
}
2024-12-16 22:20:15 +00:00
}
private struct UserResponse {
2024-12-16 22:20:15 +00:00
ulong id;
string username;
ulong createdAt;
bool isLocked;
bool isAdmin;
}
2025-01-22 17:55:14 +00:00
void registerApiEndpoints(PathHandler handler) {
handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint);
handler.addMapping(Method.GET, "/api/auth/admin/users", &usersAdminEndpoint);
handler.addMapping(Method.POST, "/api/auth/admin/users", &createUserAdminEndpoint);
handler.addMapping(Method.DELETE, "/api/auth/admin/users/:userId:ulong", &deleteUserAdminEndpoint);
handler.addMapping(Method.PUT, "/api/auth/admin/users/:userId:ulong", &updateUserAdminEndpoint);
}
private string encodePassword(string password) {
import std.digest.sha;
return toHexString(sha256Of(password)).idup;
}
2025-01-23 17:10:32 +00:00
private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connection conn) {
2024-12-16 22:20:15 +00:00
import std.base64;
import std.string : startsWith;
import std.digest.sha;
import std.algorithm : countUntil;
string headerStr = ctx.request.headers.getFirst("Authorization").orElse("");
if (headerStr.length == 0 || !startsWith(headerStr, "Basic ")) {
return Optional!User.empty;
}
string encodedCredentials = headerStr[6..$];
string decoded = cast(string) Base64.decode(encodedCredentials);
size_t idx = countUntil(decoded, ':');
string username = decoded[0..idx];
2025-01-22 17:55:14 +00:00
auto passwordHash = encodePassword(decoded[idx+1 .. $]);
2025-01-23 17:10:32 +00:00
Optional!User optUser = findOne(
conn,
"SELECT * FROM auth_user WHERE username = ?",
&User.parse,
username
);
2025-01-22 17:55:14 +00:00
if ( // Reject the user's authentication, even if they exist, if:
!optUser.isNull &&
(
optUser.value.passwordHash != passwordHash || // Password doesn't match.
optUser.value.isLocked // Account is locked.
)
) {
2024-12-16 22:20:15 +00:00
return Optional!User.empty;
}
return optUser;
}
2025-01-22 17:55:14 +00:00
/**
* Gets the current user for a given request context, based on their basic
* authentication header.
* Params:
* ctx = The request context.
2025-01-23 17:10:32 +00:00
* conn = The database connection.
2025-01-22 17:55:14 +00:00
* Returns: The user that made the request. Otherwise, a 401 is thrown.
*/
2025-01-23 17:10:32 +00:00
User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
Optional!User optUser = getUserFromBasicAuth(ctx, conn);
2024-12-16 22:20:15 +00:00
if (optUser.isNull) {
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
}
return optUser.value;
}
2025-01-22 17:55:14 +00:00
/**
* Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
* Params:
* ctx = The request context.
2025-01-23 17:10:32 +00:00
* conn = The database connection.
2025-01-22 17:55:14 +00:00
* Returns: The user that made the request.
*/
2025-01-23 17:10:32 +00:00
User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
User user = getUserOrThrow(ctx, conn);
2025-01-22 17:55:14 +00:00
if (!user.isAdmin) {
throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
}
return user;
}
private void loginEndpoint(ref HttpRequestContext ctx) {
2025-01-23 17:10:32 +00:00
Connection conn = getDb();
scope(exit) conn.close();
Optional!User optUser = getUserFromBasicAuth(ctx, conn);
2024-12-16 22:20:15 +00:00
if (optUser.isNull) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString("Invalid credentials.");
return;
}
infoF!"Login successful for user \"%s\"."(optUser.value.username);
writeJsonBody(ctx, UserResponse(
optUser.value.id,
optUser.value.username,
optUser.value.createdAt,
optUser.value.isLocked,
optUser.value.isAdmin
));
}
2025-01-22 17:55:14 +00:00
private void usersAdminEndpoint(ref HttpRequestContext ctx) {
2025-01-23 17:10:32 +00:00
Connection conn = getDb();
scope(exit) conn.close();
User user = getAdminUserOrThrow(ctx, conn);
2025-01-22 17:55:14 +00:00
uint page = ctx.request.getParamAs!uint("page", 0);
uint pageSize = ctx.request.getParamAs!uint("size", 30);
if (page < 0) page = 0;
if (pageSize < 5) pageSize = 5;
if (pageSize > 100) pageSize = 100;
uint offset = page * pageSize;
2025-01-23 17:10:32 +00:00
const query = "SELECT * FROM auth_user ORDER BY created_at DESC LIMIT ? OFFSET ?";
UserResponse[] users = findAll(
conn,
query,
&User.parse,
pageSize, offset
)
2025-01-22 17:55:14 +00:00
.map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
.array;
writeJsonBody(ctx, users);
}
private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
2025-01-23 17:10:32 +00:00
Connection conn = getDb();
scope(exit) conn.close();
User user = getAdminUserOrThrow(ctx, conn);
2025-01-22 17:55:14 +00:00
struct Payload {
string username;
string password;
}
Payload payload = readJsonPayload!(Payload)(ctx);
// TODO: Validate data
string passwordHash = encodePassword(payload.password);
const query = "
2025-01-23 17:10:32 +00:00
INSERT INTO auth_user (
username,
password_hash,
is_locked,
is_admin
) VALUES (?, ?, ?, ?)
RETURNING id
2025-01-22 17:55:14 +00:00
";
2025-01-23 17:10:32 +00:00
ulong newUserId = insertOne(
conn,
query,
payload.username, passwordHash, false, false
);
User newUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, newUserId).orElseThrow();
2025-01-22 17:55:14 +00:00
writeJsonBody(ctx, UserResponse(
newUser.id,
newUser.username,
newUser.createdAt,
newUser.isLocked,
newUser.isAdmin
));
}
private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
2025-01-23 17:10:32 +00:00
Connection conn = getDb();
scope(exit) conn.close();
User user = getAdminUserOrThrow(ctx, conn);
2025-01-22 17:55:14 +00:00
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
2025-01-23 17:10:32 +00:00
Optional!User targetUser = findOne(
conn,
"SELECT * FROM auth_user WHERE id = ?",
&User.parse,
targetUserId
);
2025-01-22 17:55:14 +00:00
if (!targetUser.isNull) {
2025-01-23 17:10:32 +00:00
update(conn, "DELETE FROM auth_user WHERE id = ?", targetUserId);
2025-01-22 17:55:14 +00:00
}
}
private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
2025-01-23 17:10:32 +00:00
Connection conn = getDb();
scope(exit) conn.close();
conn.setAutoCommit(false);
User user = getAdminUserOrThrow(ctx, conn);
2025-01-22 17:55:14 +00:00
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
2025-01-23 17:10:32 +00:00
Optional!User targetUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId);
2025-01-22 17:55:14 +00:00
if (targetUser.isNull) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("User not found.");
return;
}
JSONValue payload = ctx.request.readBodyAsJson();
if (payload.type != JSONType.object) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Expected JSON object with user properties.");
return;
}
try {
if ("username" in payload.object) {
string newUsername = payload.object["username"].str;
if (newUsername != targetUser.value.username) {
if (newUsername.length < 3 || newUsername.length > 32) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid username.");
2025-01-23 17:10:32 +00:00
conn.rollback();
2025-01-22 17:55:14 +00:00
return;
}
2025-01-23 17:10:32 +00:00
if (recordExists(conn, "SELECT id FROM auth_user WHERE username = ?", newUsername)) {
2025-01-22 17:55:14 +00:00
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Username already taken.");
2025-01-23 17:10:32 +00:00
conn.rollback();
2025-01-22 17:55:14 +00:00
return;
}
2025-01-23 17:10:32 +00:00
update(conn, "UPDATE auth_user SET username = ? WHERE id = ?", newUsername, targetUserId);
2025-01-22 17:55:14 +00:00
}
}
if ("password" in payload.object) {
string rawPassword = payload.object["password"].str;
string newPasswordHash = encodePassword(rawPassword);
if (newPasswordHash != targetUser.value.passwordHash) {
2025-01-23 17:10:32 +00:00
update(conn, "UPDATE auth_user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
2025-01-22 17:55:14 +00:00
}
}
if ("isLocked" in payload.object) {
bool newIsLocked = payload.object["isLocked"].boolean;
if (newIsLocked != targetUser.value.isLocked) {
2025-01-23 17:10:32 +00:00
update(conn, "UPDATE auth_user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
2025-01-22 17:55:14 +00:00
}
}
2025-01-23 17:10:32 +00:00
conn.commit();
User updatedUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId)
.orElseThrow();
2025-01-22 17:55:14 +00:00
writeJsonBody(ctx, UserResponse(
updatedUser.id,
updatedUser.username,
updatedUser.createdAt,
updatedUser.isLocked,
updatedUser.isAdmin
));
} catch (Exception e) {
2025-01-23 17:10:32 +00:00
conn.rollback();
2025-01-22 17:55:14 +00:00
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
}
}