Added initial basic auth scheme.
This commit is contained in:
parent
3244b9df00
commit
277af441e1
|
@ -14,3 +14,5 @@ teacher-tools-api-test-*
|
||||||
*.o
|
*.o
|
||||||
*.obj
|
*.obj
|
||||||
*.lst
|
*.lst
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"license": "proprietary",
|
"license": "proprietary",
|
||||||
"name": "teacher-tools-api",
|
"name": "teacher-tools-api",
|
||||||
"stringImportPaths": [
|
"stringImportPaths": [
|
||||||
"*"
|
"."
|
||||||
],
|
],
|
||||||
"subConfigurations": {
|
"subConfigurations": {
|
||||||
"d2sqlite3": "all-included"
|
"d2sqlite3": "all-included"
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
|
@ -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
|
||||||
|
));
|
||||||
|
}
|
|
@ -1,6 +1,31 @@
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
|
import handy_httpd.handlers.path_handler;
|
||||||
|
import std.stdio;
|
||||||
|
import d2sqlite3;
|
||||||
|
|
||||||
|
import db;
|
||||||
|
import api_modules.auth;
|
||||||
|
|
||||||
void main() {
|
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();
|
server.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void optionsEndpoint(ref HttpRequestContext ctx) {
|
||||||
|
ctx.response.status = HttpStatus.OK;
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -1,17 +1,19 @@
|
||||||
|
import type { User } from '@/api/auth'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
export interface Authenticated {
|
export interface Authenticated {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthenticationState = Authenticated | null
|
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)
|
||||||
function logIn(username: string, password: string) {
|
function logIn(username: string, password: string, user: User) {
|
||||||
state.value = { username: username, password: password }
|
state.value = { username: username, password: password, user: user }
|
||||||
}
|
}
|
||||||
function logOut() {
|
function logOut() {
|
||||||
state.value = null
|
state.value = null
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { login } from '@/api/auth';
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
@ -13,9 +14,13 @@ const authStore = useAuthStore()
|
||||||
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
|
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
// TODO: Check credentials with the API.
|
const user = await login(credentials.value.username, credentials.value.password)
|
||||||
authStore.logIn(credentials.value.username, credentials.value.password)
|
if (user) {
|
||||||
await router.replace('/')
|
authStore.logIn(credentials.value.username, credentials.value.password, user)
|
||||||
|
await router.replace('/')
|
||||||
|
} else {
|
||||||
|
console.warn('Invalid credentials.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|
Loading…
Reference in New Issue