Added initial basic auth scheme.
This commit is contained in:
parent
3244b9df00
commit
277af441e1
|
@ -14,3 +14,5 @@ teacher-tools-api-test-*
|
|||
*.o
|
||||
*.obj
|
||||
*.lst
|
||||
|
||||
*.db
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"license": "proprietary",
|
||||
"name": "teacher-tools-api",
|
||||
"stringImportPaths": [
|
||||
"*"
|
||||
"."
|
||||
],
|
||||
"subConfigurations": {
|
||||
"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.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;
|
||||
}
|
||||
|
|
|
@ -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 { 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
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue