Compare commits
2 Commits
3a668c9e11
...
ae97fa89e9
Author | SHA1 | Date |
---|---|---|
|
ae97fa89e9 | |
|
1905486d21 |
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -19,6 +19,7 @@ async function logOut() {
|
|||
<div>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/my-account" v-if="authStore.state">My Account</RouterLink>
|
||||
<RouterLink to="/admin-dashboard" v-if="authStore.admin">Admin</RouterLink>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
@ -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<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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.button-bar > button + button {
|
||||
.button-bar > * + * {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,11 @@ const router = createRouter({
|
|||
beforeEnter: [enforceAuth],
|
||||
},
|
||||
createClassroomComplianceRoutes(),
|
||||
{
|
||||
path: '/admin-dashboard',
|
||||
component: () => import('@/views/AdminDashboardView.vue'),
|
||||
beforeEnter: [enforceAuth],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
@ -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<AuthenticationState> = 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 }
|
||||
})
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
<script setup lang="ts">
|
||||
import { AuthenticationAPIClient, type User, type UserUpdatePayload } from '@/api/auth';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const apiClient = new AuthenticationAPIClient(authStore)
|
||||
|
||||
interface CreateUserData {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
const createUserFormData: Ref<CreateUserData> = ref({ username: '', password: '' })
|
||||
|
||||
const users: Ref<User[]> = ref([])
|
||||
const usersPage: Ref<number> = ref(0)
|
||||
const usersPageSize: Ref<number> = ref(50)
|
||||
|
||||
const deleteUserConfirmDialog = useTemplateRef('deleteUserConfirmDialog')
|
||||
|
||||
onMounted(() => {
|
||||
watch([usersPage, usersPageSize], fetchUsers)
|
||||
fetchUsers()
|
||||
})
|
||||
|
||||
async function fetchUsers() {
|
||||
const result = await apiClient.getUsers(usersPage.value, usersPageSize.value).handleErrorsWithAlert()
|
||||
if (result !== null) {
|
||||
users.value = result
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user: User) {
|
||||
const confirm = await deleteUserConfirmDialog.value?.show()
|
||||
if (!confirm) return
|
||||
await apiClient.deleteUser(user.id).handleErrorsWithAlert()
|
||||
fetchUsers() // Refresh the list of users.
|
||||
}
|
||||
|
||||
async function toggleLocked(user: User) {
|
||||
const payload: UserUpdatePayload = {
|
||||
isLocked: !user.isLocked
|
||||
}
|
||||
await apiClient.updateUser(user.id, payload).handleErrorsWithAlert()
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
async function doCreateUser() {
|
||||
const user = await apiClient.createUser(createUserFormData.value.username, createUserFormData.value.password)
|
||||
.handleErrorsWithAlert()
|
||||
if (user !== null) {
|
||||
createUserFormData.value = {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<h1>Administrator Dashboard</h1>
|
||||
<p>
|
||||
On this page, you'll find tools for managing users and checking audit information.
|
||||
</p>
|
||||
|
||||
<h3>Users</h3>
|
||||
<div class="button-bar">
|
||||
<button type="button" :disabled="usersPage < 1" @click="usersPage -= 1">Previous Page</button>
|
||||
<span>Page: {{ usersPage }}</span>
|
||||
<button type="button" @click="usersPage += 1">Next Page</button>
|
||||
<select v-model="usersPageSize">
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Created At</th>
|
||||
<th>Admin</th>
|
||||
<th>Locked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ new Date(user.createdAt).toLocaleString() }}</td>
|
||||
<td>{{ user.isAdmin }}</td>
|
||||
<td>{{ user.isLocked }}</td>
|
||||
<td>
|
||||
<div>
|
||||
<button type="button" @click="deleteUser(user)">Delete</button>
|
||||
|
||||
<button type="button" @click="toggleLocked(user)" v-text="user.isLocked ? 'Unlock' : 'Lock'"></button>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form @submit.prevent="doCreateUser">
|
||||
<h3>Create User</h3>
|
||||
<div>
|
||||
<label for="create-user-username">Username</label>
|
||||
<input id="create-user-username" v-model="createUserFormData.username" type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-user-password">Password</label>
|
||||
<input id="create-user-password" v-model="createUserFormData.password" type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmDialog ref="deleteUserConfirmDialog">
|
||||
<p>
|
||||
Are you sure you want to delete this user?
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
</main>
|
||||
</template>
|
Loading…
Reference in New Issue