diff --git a/api/source/api_modules/auth.d b/api/source/api_modules/auth.d index 40dd086..aa40a59 100644 --- a/api/source/api_modules/auth.d +++ b/api/source/api_modules/auth.d @@ -2,8 +2,12 @@ module api_modules.auth; import handy_httpd; import handy_httpd.components.optional; +import handy_httpd.handlers.path_handler; import slf4d; import d2sqlite3; +import std.algorithm : map; +import std.array : array; +import std.json; import db; import data_utils; @@ -25,7 +29,20 @@ private struct UserResponse { 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.string : startsWith; import std.digest.sha; @@ -39,25 +56,54 @@ Optional!User getUser(ref HttpRequestContext ctx, ref Database db) { string decoded = cast(string) Base64.decode(encodedCredentials); size_t idx = countUntil(decoded, ':'); 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); - 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 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) { - Optional!User optUser = getUser(ctx, db); + Optional!User optUser = getUserFromBasicAuth(ctx, db); if (optUser.isNull) { throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); } 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(); - Optional!User optUser = getUser(ctx, db); + Optional!User optUser = getUserFromBasicAuth(ctx, db); if (optUser.isNull) { ctx.response.status = HttpStatus.UNAUTHORIZED; ctx.response.writeBodyString("Invalid credentials."); @@ -72,3 +118,126 @@ void loginEndpoint(ref HttpRequestContext ctx) { 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); + } +} diff --git a/api/source/app.d b/api/source/app.d index 308d93f..a98711e 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -4,7 +4,7 @@ import d2sqlite3; import std.process; import db; -import api_modules.auth; +static import api_modules.auth; static import api_modules.classroom_compliance; void main() { @@ -32,7 +32,7 @@ void main() { PathHandler handler = new PathHandler(); 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); HttpServer server = new HttpServer(handler, config); diff --git a/api/source/sample_data.d b/api/source/sample_data.d index 1b2f8cb..b83506c 100644 --- a/api/source/sample_data.d +++ b/api/source/sample_data.d @@ -34,6 +34,11 @@ private const STUDENT_NAMES = [ void insertSampleData(ref Database db) { 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 normalUserId = addUser(db, "test2", "test", false, false); Random rand = Random(0); diff --git a/app/src/App.vue b/app/src/App.vue index 634721a..04f33a9 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -19,6 +19,7 @@ async function logOut() {
Home My Account + Admin
diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 7679a9f..a4fec17 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -10,6 +10,12 @@ export interface User { isAdmin: boolean } +export interface UserUpdatePayload { + username?: string + password?: string + isLocked?: boolean +} + export class AuthenticationAPIClient extends APIClient { constructor(authStore: AuthStoreType) { super(BASE_URL, authStore) @@ -24,4 +30,20 @@ export class AuthenticationAPIClient extends APIClient { }) return new APIResponse(this.handleAPIResponse(promise)) } + + getUsers(page: number = 0, pageSize: number = 30): APIResponse { + return super.get(`/admin/users?page=${page}&size=${pageSize}`) + } + + deleteUser(userId: number): APIResponse { + return super.delete(`/admin/users/${userId}`) + } + + updateUser(userId: number, payload: UserUpdatePayload): APIResponse { + return super.put(`/admin/users/${userId}`, payload) + } + + createUser(username: string, password: string): APIResponse { + return super.post(`/admin/users/`, { username: username, password: password }) + } } diff --git a/app/src/assets/base.css b/app/src/assets/base.css index f9700c1..613aad3 100644 --- a/app/src/assets/base.css +++ b/app/src/assets/base.css @@ -8,7 +8,7 @@ margin-bottom: 0.5em; } -.button-bar > button + button { +.button-bar > * + * { margin-left: 0.5em; } diff --git a/app/src/router/index.ts b/app/src/router/index.ts index f9254f9..76d081b 100644 --- a/app/src/router/index.ts +++ b/app/src/router/index.ts @@ -35,6 +35,11 @@ const router = createRouter({ beforeEnter: [enforceAuth], }, createClassroomComplianceRoutes(), + { + path: '/admin-dashboard', + component: () => import('@/views/AdminDashboardView.vue'), + beforeEnter: [enforceAuth], + }, ], }) diff --git a/app/src/stores/auth.ts b/app/src/stores/auth.ts index 347ee71..29a718b 100644 --- a/app/src/stores/auth.ts +++ b/app/src/stores/auth.ts @@ -1,6 +1,6 @@ import type { User } from '@/api/auth' import { defineStore } from 'pinia' -import { ref, type Ref } from 'vue' +import { computed, ref, type Ref } from 'vue' export interface Authenticated { username: string @@ -12,6 +12,7 @@ export type AuthenticationState = Authenticated | null export const useAuthStore = defineStore('auth', () => { const state: Ref = ref(null) + const admin = computed(() => state.value && state.value.user.isAdmin) function logIn(username: string, password: string, 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.') return btoa(state.value.username + ':' + state.value.password) } - return { state, logIn, logOut, getBasicAuth } + return { state, admin, logIn, logOut, getBasicAuth } })