From 277af441e16792cd72260d962972d68c50d76bbd Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 16 Dec 2024 17:20:15 -0500 Subject: [PATCH] Added initial basic auth scheme. --- api/.gitignore | 2 + api/dub.json | 2 +- api/schema.sql | 16 +++++++ api/source/api_modules/auth.d | 78 +++++++++++++++++++++++++++++++++++ api/source/app.d | 27 +++++++++++- api/source/data_utils.d | 40 ++++++++++++++++++ api/source/db.d | 78 +++++++++++++++++++++++++++++++++++ app/src/api/auth.ts | 21 ++++++++++ app/src/stores/auth.ts | 6 ++- app/src/views/LoginView.vue | 11 +++-- 10 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 api/schema.sql create mode 100644 api/source/api_modules/auth.d create mode 100644 api/source/data_utils.d create mode 100644 api/source/db.d create mode 100644 app/src/api/auth.ts diff --git a/api/.gitignore b/api/.gitignore index bd296f9..9b89f45 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -14,3 +14,5 @@ teacher-tools-api-test-* *.o *.obj *.lst + +*.db diff --git a/api/dub.json b/api/dub.json index b4ab2cb..17b3394 100644 --- a/api/dub.json +++ b/api/dub.json @@ -13,7 +13,7 @@ "license": "proprietary", "name": "teacher-tools-api", "stringImportPaths": [ - "*" + "." ], "subConfigurations": { "d2sqlite3": "all-included" diff --git a/api/schema.sql b/api/schema.sql new file mode 100644 index 0000000..a704a95 --- /dev/null +++ b/api/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE user ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + is_locked INTEGER NOT NULL, + is_admin INTEGER NOT NULL +); + +INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES ( + 'test', + '9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08', + 1734380300, + 0, + 1 +); diff --git a/api/source/api_modules/auth.d b/api/source/api_modules/auth.d new file mode 100644 index 0000000..64f93db --- /dev/null +++ b/api/source/api_modules/auth.d @@ -0,0 +1,78 @@ +module api_modules.auth; + +import handy_httpd; +import handy_httpd.components.optional; +import slf4d; +import d2sqlite3; + +import db; +import data_utils; + +struct User { + const ulong id; + const string username; + @Column("password_hash") + const string passwordHash; + @Column("created_at") + const ulong createdAt; + @Column("is_locked") + const bool isLocked; + @Column("is_admin") + const bool isAdmin; +} + +struct UserResponse { + ulong id; + string username; + ulong createdAt; + bool isLocked; + bool isAdmin; +} + +Optional!User getUser(ref HttpRequestContext ctx) { + 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]; + auto passwordHash = toHexString(sha256Of(decoded[idx+1 .. $])); + Database db = getDb(); + Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username); + if (!optUser.isNull && optUser.value.passwordHash != passwordHash) { + return Optional!User.empty; + } + return optUser; +} + +User getUserOrThrow(ref HttpRequestContext ctx) { + Optional!User optUser = getUser(ctx); + if (optUser.isNull) { + throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); + } + return optUser.value; +} + +void loginEndpoint(ref HttpRequestContext ctx) { + Optional!User optUser = getUser(ctx); + 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 + )); +} diff --git a/api/source/app.d b/api/source/app.d index 8e1ed0f..b45be0e 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -1,6 +1,31 @@ import handy_httpd; +import handy_httpd.handlers.path_handler; +import std.stdio; +import d2sqlite3; + +import db; +import api_modules.auth; void main() { - HttpServer server = new HttpServer(); + ServerConfig config; + config.enableWebSockets = false; + config.port = 8080; + config.workerPoolSize = 3; + + config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; + config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; + config.defaultHeaders["Access-Control-Request-Method"] = "*"; + config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization, Content-Length, Content-Type"; + + + PathHandler handler = new PathHandler(); + handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint); + handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint); + + HttpServer server = new HttpServer(handler, config); server.start(); } + +void optionsEndpoint(ref HttpRequestContext ctx) { + ctx.response.status = HttpStatus.OK; +} diff --git a/api/source/data_utils.d b/api/source/data_utils.d new file mode 100644 index 0000000..a9c5665 --- /dev/null +++ b/api/source/data_utils.d @@ -0,0 +1,40 @@ +module data_utils; + +import handy_httpd; +import asdf; + +public import handy_httpd.components.optional; + + +/** + * Reads a JSON payload into a type T. Throws an `HttpStatusException` if + * the data cannot be read or converted to the given type, with a 400 BAD + * REQUEST status. + * Params: + * ctx = The request context to read from. + * Returns: The data that was read. + */ +T readJsonPayload(T)(ref HttpRequestContext ctx) { + try { + string requestBody = ctx.request.readBodyAsString(); + return deserialize!T(requestBody); + } catch (SerdeException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST); + } +} + +/** + * Writes data of type T to a JSON response body. Throws an `HttpStatusException` + * with status 501 INTERNAL SERVER ERROR if serialization fails. + * Params: + * ctx = The request context to write to. + * data = The data to write. + */ +void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) { + try { + string jsonStr = serializeToJson(data); + ctx.response.writeBodyString(jsonStr, "application/json"); + } catch (SerdeException e) { + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/api/source/db.d b/api/source/db.d new file mode 100644 index 0000000..ae6ce6f --- /dev/null +++ b/api/source/db.d @@ -0,0 +1,78 @@ +module db; + +import std.algorithm; +import std.array; +import std.typecons; +import std.conv; + +import d2sqlite3; +import slf4d; +import handy_httpd.components.optional; + +struct Column { + const string name; +} + +Database getDb() { + import std.file; + bool shouldInitDb = !exists("teacher-tools.db"); + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + if (d2sqlite3.threadSafe()) { + flags |= SQLITE_OPEN_NOMUTEX; + } + Database db = Database("teacher-tools.db", flags); + db.execute("PRAGMA foreign_keys=ON"); + const string schema = import("schema.sql"); + if (shouldInitDb) { + db.run(schema); + info("Initialized database schema."); + } + return db; +} + +private string[] getColumnNames(T)() { + import std.string : toLower; + alias members = __traits(allMembers, T); + string[members.length] columnNames; + static foreach (i; 0 .. members.length) { + static if (__traits(getAttributes, __traits(getMember, T, members[i])).length > 0) { + columnNames[i] = toLower(__traits(getAttributes, __traits(getMember, T, members[i]))[0].name); + } else { + columnNames[i] = toLower(members[i]); + } + } + return columnNames.dup; +} + +private string getArgsStr(T)() { + import std.traits : RepresentationTypeTuple; + alias types = RepresentationTypeTuple!T; + string argsStr = ""; + static foreach (i, type; types) { + argsStr ~= "row.peek!(" ~ type.stringof ~ ")(" ~ i.to!string ~ ")"; + static if (i + 1 < types.length) { + argsStr ~= ", "; + } + } + return argsStr; +} + +T parseRow(T)(Row row) { + mixin("T t = T(" ~ getArgsStr!T ~ ");"); + return t; +} + +T[] findAll(T, Args...)(Database db, string query, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + ResultRange result = stmt.execute(); + return result.map!(row => parseRow!T(row)).array; +} + +Optional!T findOne(T, Args...)(Database db, string query, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + ResultRange result = stmt.execute(); + if (result.empty) return Optional!T.empty; + return Optional!T.of(parseRow!T(result.front)); +} diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts new file mode 100644 index 0000000..7fe6fd0 --- /dev/null +++ b/app/src/api/auth.ts @@ -0,0 +1,21 @@ +export interface User { + id: number + username: string + createdAt: Date + isLocked: boolean + isAdmin: boolean +} + +export async function login(username: string, password: string): Promise { + const basicAuth = btoa(username + ':' + password) + const response = await fetch(import.meta.env.VITE_API_AUTH_URL + '/login', { + method: 'POST', + headers: { + Authorization: 'Basic ' + basicAuth, + }, + }) + if (!response.ok) { + return null + } + return (await response.json()) as User +} diff --git a/app/src/stores/auth.ts b/app/src/stores/auth.ts index 2ff2eed..eb62bb9 100644 --- a/app/src/stores/auth.ts +++ b/app/src/stores/auth.ts @@ -1,17 +1,19 @@ +import type { User } from '@/api/auth' import { defineStore } from 'pinia' import { ref, type Ref } from 'vue' export interface Authenticated { username: string password: string + user: User } export type AuthenticationState = Authenticated | null export const useAuthStore = defineStore('auth', () => { const state: Ref = ref(null) - function logIn(username: string, password: string) { - state.value = { username: username, password: password } + function logIn(username: string, password: string, user: User) { + state.value = { username: username, password: password, user: user } } function logOut() { state.value = null diff --git a/app/src/views/LoginView.vue b/app/src/views/LoginView.vue index 6e5f785..676acc4 100644 --- a/app/src/views/LoginView.vue +++ b/app/src/views/LoginView.vue @@ -1,4 +1,5 @@