Added admin dashboard.
This commit is contained in:
parent
3a668c9e11
commit
1905486d21
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,11 @@ const router = createRouter({
|
||||||
beforeEnter: [enforceAuth],
|
beforeEnter: [enforceAuth],
|
||||||
},
|
},
|
||||||
createClassroomComplianceRoutes(),
|
createClassroomComplianceRoutes(),
|
||||||
|
{
|
||||||
|
path: '/admin-dashboard',
|
||||||
|
component: () => import('@/views/AdminDashboardView.vue'),
|
||||||
|
beforeEnter: [enforceAuth],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue