Added admin dashboard.

This commit is contained in:
Andrew Lalis 2025-01-22 12:55:14 -05:00
parent 3a668c9e11
commit 1905486d21
8 changed files with 214 additions and 11 deletions

View File

@ -2,8 +2,12 @@ module api_modules.auth;
import handy_httpd; import handy_httpd;
import handy_httpd.components.optional; import handy_httpd.components.optional;
import handy_httpd.handlers.path_handler;
import slf4d; import slf4d;
import d2sqlite3; import d2sqlite3;
import std.algorithm : map;
import std.array : array;
import std.json;
import db; import db;
import data_utils; import data_utils;
@ -25,7 +29,20 @@ private struct UserResponse {
bool isAdmin; bool isAdmin;
} }
Optional!User getUser(ref HttpRequestContext ctx, ref Database db) { 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;
}
private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Database db) {
import std.base64; import std.base64;
import std.string : startsWith; import std.string : startsWith;
import std.digest.sha; import std.digest.sha;
@ -39,25 +56,54 @@ Optional!User getUser(ref HttpRequestContext ctx, ref Database db) {
string decoded = cast(string) Base64.decode(encodedCredentials); string decoded = cast(string) Base64.decode(encodedCredentials);
size_t idx = countUntil(decoded, ':'); size_t idx = countUntil(decoded, ':');
string username = decoded[0..idx]; string username = decoded[0..idx];
auto passwordHash = toHexString(sha256Of(decoded[idx+1 .. $])); auto passwordHash = encodePassword(decoded[idx+1 .. $]);
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username); Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
if (!optUser.isNull && optUser.value.passwordHash != passwordHash) { 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.
)
) {
return Optional!User.empty; return Optional!User.empty;
} }
return optUser; return optUser;
} }
/**
* Gets the current user for a given request context, based on their basic
* authentication header.
* Params:
* ctx = The request context.
* db = The database to query.
* Returns: The user that made the request. Otherwise, a 401 is thrown.
*/
User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) { User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
Optional!User optUser = getUser(ctx, db); Optional!User optUser = getUserFromBasicAuth(ctx, db);
if (optUser.isNull) { if (optUser.isNull) {
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
} }
return optUser.value; return optUser.value;
} }
void loginEndpoint(ref HttpRequestContext ctx) { /**
* Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
* Params:
* ctx = The request context.
* db = The database to query.
* Returns: The user that made the request.
*/
User getAdminUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
User user = getUserOrThrow(ctx, db);
if (!user.isAdmin) {
throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
}
return user;
}
private void loginEndpoint(ref HttpRequestContext ctx) {
Database db = getDb(); Database db = getDb();
Optional!User optUser = getUser(ctx, db); Optional!User optUser = getUserFromBasicAuth(ctx, db);
if (optUser.isNull) { if (optUser.isNull) {
ctx.response.status = HttpStatus.UNAUTHORIZED; ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString("Invalid credentials."); ctx.response.writeBodyString("Invalid credentials.");
@ -72,3 +118,126 @@ void loginEndpoint(ref HttpRequestContext ctx) {
optUser.value.isAdmin optUser.value.isAdmin
)); ));
} }
private void usersAdminEndpoint(ref HttpRequestContext ctx) {
Database db = getDb();
User user = getAdminUserOrThrow(ctx, db);
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;
const query = "SELECT * FROM user ORDER BY created_at DESC LIMIT ? OFFSET ?";
UserResponse[] users = findAll!(User)(db, query, pageSize, offset)
.map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
.array;
writeJsonBody(ctx, users);
}
private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
Database db = getDb();
User user = getAdminUserOrThrow(ctx, db);
struct Payload {
string username;
string password;
}
Payload payload = readJsonPayload!(Payload)(ctx);
// TODO: Validate data
string passwordHash = encodePassword(payload.password);
const query = "
INSERT INTO user (
username,
password_hash,
created_at,
is_locked,
is_admin
) VALUES (?, ?, ?, ?, ?)
";
db.execute(query, payload.username, passwordHash, getUnixTimestampMillis(), false, false);
ulong newUserId = db.lastInsertRowid();
User newUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", newUserId).orElseThrow();
writeJsonBody(ctx, UserResponse(
newUser.id,
newUser.username,
newUser.createdAt,
newUser.isLocked,
newUser.isAdmin
));
}
private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
Database db = getDb();
User user = getAdminUserOrThrow(ctx, db);
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
if (!targetUser.isNull) {
db.execute("DELETE FROM user WHERE id = ?", targetUserId);
}
}
private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
Database db = getDb();
User user = getAdminUserOrThrow(ctx, db);
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
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;
}
db.begin();
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.");
db.rollback();
return;
}
if (canFind(db, "SELECT id FROM user WHERE username = ?", newUsername)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Username already taken.");
db.rollback();
return;
}
db.execute("UPDATE user SET username = ? WHERE id = ?", newUsername, targetUserId);
}
}
if ("password" in payload.object) {
string rawPassword = payload.object["password"].str;
string newPasswordHash = encodePassword(rawPassword);
if (newPasswordHash != targetUser.value.passwordHash) {
db.execute("UPDATE user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
}
}
if ("isLocked" in payload.object) {
bool newIsLocked = payload.object["isLocked"].boolean;
if (newIsLocked != targetUser.value.isLocked) {
db.execute("UPDATE user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
}
}
db.commit();
User updatedUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId).orElseThrow();
writeJsonBody(ctx, UserResponse(
updatedUser.id,
updatedUser.username,
updatedUser.createdAt,
updatedUser.isLocked,
updatedUser.isAdmin
));
} catch (Exception e) {
db.rollback();
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
}
}

View File

@ -4,7 +4,7 @@ import d2sqlite3;
import std.process; import std.process;
import db; import db;
import api_modules.auth; static import api_modules.auth;
static import api_modules.classroom_compliance; static import api_modules.classroom_compliance;
void main() { void main() {
@ -32,7 +32,7 @@ void main() {
PathHandler handler = new PathHandler(); PathHandler handler = new PathHandler();
handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint); handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint);
handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint); api_modules.auth.registerApiEndpoints(handler);
api_modules.classroom_compliance.registerApiEndpoints(handler); api_modules.classroom_compliance.registerApiEndpoints(handler);
HttpServer server = new HttpServer(handler, config); HttpServer server = new HttpServer(handler, config);

View File

@ -34,6 +34,11 @@ private const STUDENT_NAMES = [
void insertSampleData(ref Database db) { void insertSampleData(ref Database db) {
db.begin(); db.begin();
addUser(db, "sample-user-A", "test", false, false);
addUser(db, "sample-user-B", "test", true, false);
addUser(db, "sample-user-C", "test", false, false);
addUser(db, "sample-user-D", "test", false, false);
ulong adminUserId = addUser(db, "test", "test", false, true); ulong adminUserId = addUser(db, "test", "test", false, true);
ulong normalUserId = addUser(db, "test2", "test", false, false); ulong normalUserId = addUser(db, "test2", "test", false, false);
Random rand = Random(0); Random rand = Random(0);

View File

@ -19,6 +19,7 @@ async function logOut() {
<div> <div>
<RouterLink to="/">Home</RouterLink> <RouterLink to="/">Home</RouterLink>
<RouterLink to="/my-account" v-if="authStore.state">My Account</RouterLink> <RouterLink to="/my-account" v-if="authStore.state">My Account</RouterLink>
<RouterLink to="/admin-dashboard" v-if="authStore.admin">Admin</RouterLink>
</div> </div>
<div> <div>

View File

@ -10,6 +10,12 @@ export interface User {
isAdmin: boolean isAdmin: boolean
} }
export interface UserUpdatePayload {
username?: string
password?: string
isLocked?: boolean
}
export class AuthenticationAPIClient extends APIClient { export class AuthenticationAPIClient extends APIClient {
constructor(authStore: AuthStoreType) { constructor(authStore: AuthStoreType) {
super(BASE_URL, authStore) super(BASE_URL, authStore)
@ -24,4 +30,20 @@ export class AuthenticationAPIClient extends APIClient {
}) })
return new APIResponse(this.handleAPIResponse(promise)) return new APIResponse(this.handleAPIResponse(promise))
} }
getUsers(page: number = 0, pageSize: number = 30): APIResponse<User[]> {
return super.get(`/admin/users?page=${page}&size=${pageSize}`)
}
deleteUser(userId: number): APIResponse<void> {
return super.delete(`/admin/users/${userId}`)
}
updateUser(userId: number, payload: UserUpdatePayload): APIResponse<User> {
return super.put(`/admin/users/${userId}`, payload)
}
createUser(username: string, password: string): APIResponse<User> {
return super.post(`/admin/users/`, { username: username, password: password })
}
} }

View File

@ -8,7 +8,7 @@
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
.button-bar > button + button { .button-bar > * + * {
margin-left: 0.5em; margin-left: 0.5em;
} }

View File

@ -35,6 +35,11 @@ const router = createRouter({
beforeEnter: [enforceAuth], beforeEnter: [enforceAuth],
}, },
createClassroomComplianceRoutes(), createClassroomComplianceRoutes(),
{
path: '/admin-dashboard',
component: () => import('@/views/AdminDashboardView.vue'),
beforeEnter: [enforceAuth],
},
], ],
}) })

View File

@ -1,6 +1,6 @@
import type { User } from '@/api/auth' import type { User } from '@/api/auth'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, type Ref } from 'vue' import { computed, ref, type Ref } from 'vue'
export interface Authenticated { export interface Authenticated {
username: string username: string
@ -12,6 +12,7 @@ export type AuthenticationState = Authenticated | null
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const state: Ref<AuthenticationState> = ref(null) const state: Ref<AuthenticationState> = ref(null)
const admin = computed(() => state.value && state.value.user.isAdmin)
function logIn(username: string, password: string, user: User) { function logIn(username: string, password: string, user: User) {
state.value = { username: username, password: password, user: user } state.value = { username: username, password: password, user: user }
} }
@ -22,5 +23,5 @@ export const useAuthStore = defineStore('auth', () => {
if (!state.value) throw new Error('User is not authenticated.') if (!state.value) throw new Error('User is not authenticated.')
return btoa(state.value.username + ':' + state.value.password) return btoa(state.value.username + ':' + state.value.password)
} }
return { state, logIn, logOut, getBasicAuth } return { state, admin, logIn, logOut, getBasicAuth }
}) })