Added initial basic auth scheme.

This commit is contained in:
Andrew Lalis 2024-12-16 17:20:15 -05:00
parent 3244b9df00
commit 277af441e1
10 changed files with 274 additions and 7 deletions

2
api/.gitignore vendored
View File

@ -14,3 +14,5 @@ teacher-tools-api-test-*
*.o
*.obj
*.lst
*.db

View File

@ -13,7 +13,7 @@
"license": "proprietary",
"name": "teacher-tools-api",
"stringImportPaths": [
"*"
"."
],
"subConfigurations": {
"d2sqlite3": "all-included"

16
api/schema.sql Normal file
View File

@ -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
);

View File

@ -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
));
}

View File

@ -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;
}

40
api/source/data_utils.d Normal file
View File

@ -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);
}
}

78
api/source/db.d Normal file
View File

@ -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));
}

21
app/src/api/auth.ts Normal file
View File

@ -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<User | null> {
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
}

View File

@ -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<AuthenticationState> = 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

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { login } from '@/api/auth';
import { useAuthStore } from '@/stores/auth'
import { ref, type Ref } from 'vue'
import { useRouter } from 'vue-router'
@ -13,9 +14,13 @@ const authStore = useAuthStore()
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
async function doLogin() {
// TODO: Check credentials with the API.
authStore.logIn(credentials.value.username, credentials.value.password)
const user = await login(credentials.value.username, credentials.value.password)
if (user) {
authStore.logIn(credentials.value.username, credentials.value.password, user)
await router.replace('/')
} else {
console.warn('Invalid credentials.')
}
}
</script>
<template>