Added more API stuff, front-end stuff too.

This commit is contained in:
Andrew Lalis 2023-08-17 11:55:05 -04:00
parent 57d92d95a4
commit 69ea579ea3
14 changed files with 590 additions and 46 deletions

View File

@ -14,3 +14,5 @@ litelist-api-test-*
*.o
*.obj
*.lst
users/

View File

@ -4,6 +4,7 @@
],
"copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": {
"botan": "~>1.13.5",
"d2sqlite3": "~>1.0.0",
"handy-httpd": "~>7.9.3",
"jwt": "~>0.4.0",

View File

@ -1,10 +1,13 @@
{
"fileVersion": 1,
"versions": {
"botan": "1.13.5",
"botan-math": "1.0.4",
"d2sqlite3": "1.0.0",
"handy-httpd": "7.9.3",
"httparsed": "1.2.1",
"jwt": "0.4.0",
"memutils": "1.0.9",
"slf4d": "2.4.2",
"streams": "3.5.0"
}

View File

@ -14,6 +14,7 @@ private HttpServer initServer() {
import handy_httpd.handlers.path_delegating_handler;
import handy_httpd.handlers.filtered_handler;
import auth;
import lists;
ServerConfig config = ServerConfig.defaultValues();
config.enableWebSockets = false;
@ -21,20 +22,29 @@ private HttpServer initServer() {
config.port = 8080;
config.connectionQueueSize = 10;
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
config.defaultHeaders["Vary"] = "origin";
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
auto mainHandler = new PathDelegatingHandler();
mainHandler.addMapping(Method.GET, "/status", (ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online");
});
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
// Authenticated endpoints are protected by the TokenFilter.
auto authEndpoints = new PathDelegatingHandler();
auto authHandler = new FilteredRequestHandler(
authEndpoints,
[new TokenFilter]
);
auto optionsHandler = toHandler((ref HttpRequestContext ctx) {
ctx.response.setStatus(HttpStatus.OK);
});
mainHandler.addMapping(Method.POST, "/register", &createNewUser);
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
mainHandler.addMapping(Method.GET, "/me", &getMyUser);
mainHandler.addMapping(Method.OPTIONS, "/**", optionsHandler);
mainHandler.addMapping(Method.DELETE, "/me", &deleteMyUser);
mainHandler.addMapping(Method.GET, "/lists", &getNoteLists);
mainHandler.addMapping(Method.POST, "/lists", &createNoteList);
mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList);
return new HttpServer(mainHandler, config);
}

View File

@ -2,18 +2,95 @@ module auth;
import handy_httpd;
import handy_httpd.handlers.filtered_handler;
import jwt.jwt;
import jwt.algorithms;
import slf4d;
import std.datetime;
import std.json;
import std.path;
import std.file;
import std.typecons;
import data;
void handleLogin(ref HttpRequestContext ctx) {
JSONValue loginData = ctx.request.readBodyAsJson();
if ("username" !in loginData.object || "password" !in loginData.object) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Invalid login request data. Expected username and password.");
return;
}
string username = loginData.object["username"].str;
infoF!"Got login request for user \"%s\"."(username);
string password = loginData.object["password"].str;
Nullable!User userNullable = userDataSource.getUser(username);
if (userNullable.isNull) {
infoF!"User \"%s\" doesn't exist."(username);
sendUnauthenticatedResponse(ctx.response);
return;
}
User user = userNullable.get();
import botan.passhash.bcrypt : checkBcrypt;
if (!checkBcrypt(password, user.passwordHash)) {
sendUnauthenticatedResponse(ctx.response);
return;
}
JSONValue resp = JSONValue(string[string].init);
resp.object["token"] = "authtoken";
resp.object["token"] = generateToken(user);
ctx.response.writeBodyString(resp.toString(), "application/json");
}
struct User {
string username;
string email;
string passwordHash;
void createNewUser(ref HttpRequestContext ctx) {
JSONValue userData = ctx.request.readBodyAsJson();
string username = userData.object["username"].str;
string email = userData.object["email"].str;
string password = userData.object["password"].str;
if (!userDataSource.getUser(username).isNull) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Username is taken.");
return;
}
import botan.passhash.bcrypt : generateBcrypt;
import botan.rng.auto_rng;
RandomNumberGenerator rng = new AutoSeededRNG();
string passwordHash = generateBcrypt(password, rng, 12);
userDataSource.createUser(username, email, passwordHash);
}
void getMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue resp = JSONValue(string[string].init);
resp.object["username"] = JSONValue(auth.user.username);
resp.object["email"] = JSONValue(auth.user.email);
ctx.response.writeBodyString(resp.toString(), "application/json");
}
void deleteMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
userDataSource.deleteUser(auth.user.username);
}
private string generateToken(in User user) {
Token token = new Token(JWTAlgorithm.HS512);
token.claims.aud("litelist-api");
token.claims.sub(user.username);
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
token.claims.iss("litelist-api");
return token.encode("supersecret");// TODO: Extract secret.
}
private void sendUnauthenticatedResponse(ref HttpResponse resp) {
resp.setStatus(HttpStatus.UNAUTHORIZED);
resp.writeBodyString("Invalid credentials.");
}
struct AuthContext {
@ -41,32 +118,62 @@ class AuthContextHolder {
i.context = AuthContext(token, user);
}
static AuthContext getOrThrow() {
auto i = getInstance();
if (!i.authenticated) throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "No authentication context.");
return i.context;
}
private bool authenticated;
private AuthContext context;
}
class TokenFilter : HttpRequestFilter {
/**
* Validates any request that should be authenticated with an access token,
* and sets the AuthContextHolder's context if the user is authenticated.
* Otherwise, sends an appropriate "unauthorized" response.
* Params:
* ctx = The request context to validate.
* Returns: True if the user is authenticated, or false otherwise.
*/
bool validateAuthenticatedRequest(ref HttpRequestContext ctx) {
immutable HEADER_NAME = "Authorization";
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
AuthContextHolder.reset();
if (!ctx.request.hasHeader(HEADER_NAME)) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Missing Authorization header.");
return;
return false;
}
string authHeader = ctx.request.getHeader(HEADER_NAME);
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Invalid bearer token authorization header.");
return;
return false;
}
string rawToken = authHeader[7 .. $];
string username;
try {
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]);
username = token.claims.sub;
} catch (Exception e) {
warn("Failed to verify user token.", e);
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token.");
}
// TODO: Validate token and fetch user.
User user = User("bleh", "bleh@example.com", "faef9834rfe");
Nullable!User user = userDataSource.getUser(username);
if (user.isNull) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("User does not exist.");
return false;
}
AuthContextHolder.setContext(rawToken, user);
filterChain.doFilter(ctx);
AuthContextHolder.setContext(rawToken, user.get);
return true;
}
class TokenFilter : HttpRequestFilter {
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx);
}
}

213
litelist-api/source/data.d Normal file
View File

@ -0,0 +1,213 @@
module data;
import handy_httpd;
import d2sqlite3;
import std.path;
import std.file;
import std.stdio;
import std.typecons;
import std.string;
import std.json;
static UserDataSource userDataSource;
static this() {
userDataSource = new FsSqliteDataSource();
}
struct User {
string username;
string email;
string passwordHash;
}
struct NoteList {
ulong id;
string name;
uint ordinality;
string description;
Note[] notes;
}
struct Note {
ulong id;
ulong noteListId;
uint ordinality;
string content;
}
interface UserDataSource {
User createUser(string username, string email, string passwordHash);
void deleteUser(string username);
Nullable!User getUser(string username);
NoteList[] getLists(string username);
NoteList createNoteList(string username, string name, string description = null);
void deleteNoteList(string username, ulong id);
Note createNote(string username, ulong noteListId, string content);
void deleteNote(string username, ulong id);
}
private immutable string USERS_DIR = "users";
private immutable string DATA_FILE = "user.json";
private immutable string DB_FILE = "notes.sqlite";
class FsSqliteDataSource : UserDataSource {
User createUser(string username, string email, string passwordHash) {
string dirPath = buildPath(USERS_DIR, username);
if (exists(dirPath)) throw new Exception("User already has a directory.");
mkdir(dirPath);
string dataPath = buildPath(dirPath, DATA_FILE);
JSONValue userObj = JSONValue(string[string].init);
userObj.object["username"] = username;
userObj.object["email"] = email;
userObj.object["passwordHash"] = passwordHash;
std.file.write(dataPath, userObj.toPrettyString());
// Set up a default list.
NoteList defaultList = this.createNoteList(username, "Default", "Your default list of notes.");
this.createNote(username, defaultList.id, "Here's an example note that was added to the Default list.");
return User(username, email, passwordHash);
}
void deleteUser(string username) {
string dirPath = buildPath(USERS_DIR, username);
if (exists(dirPath)) rmdirRecurse(dirPath);
}
Nullable!User getUser(string username) {
string dataPath = buildPath(USERS_DIR, username, DATA_FILE);
if (exists(dataPath) && isFile(dataPath)) {
JSONValue userObj = parseJSON(strip(readText(dataPath)));
return nullable(User(
userObj.object["username"].str,
userObj.object["email"].str,
userObj.object["passwordHash"].str
));
}
return Nullable!User.init;
}
NoteList[] getLists(string username) {
Database db = getDb(username);
ResultRange results = db.execute("SELECT * FROM note_list ORDER BY ordinality ASC");
NoteList[] lists;
foreach (Row row; results) {
lists ~= parseNoteList(row);
}
// Now eager-fetch notes for each list.
Statement stmt = db.prepare("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC");
foreach (ref list; lists) {
stmt.bind(1, list.id);
ResultRange noteResult = stmt.execute();
foreach (row; noteResult) list.notes ~= parseNote(row);
stmt.reset();
}
return lists;
}
NoteList createNoteList(string username, string name, string description = null) {
Database db = getDb(username);
Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?");
existsStatement.bind(1, name);
ResultRange existsResult = existsStatement.execute();
if (existsResult.oneValue!int() > 0) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists.");
Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint);
uint ordinality = 0;
if (!ordResult.isNull) ordinality = ordResult.get();
Statement stmt = db.prepare("INSERT INTO note_list (name, ordinality, description) VALUES (?, ?, ?)");
stmt.bind(1, name);
stmt.bind(2, ordinality);
stmt.bind(3, description);
stmt.execute();
return NoteList(db.lastInsertRowid(), name, ordinality, description, []);
}
void deleteNoteList(string username, ulong id) {
Database db = getDb(username);
Statement stmt1 = db.prepare("DELETE FROM note WHERE note_list_id = ?");
stmt1.bind(1, id);
stmt1.execute();
Statement stmt2 = db.prepare("DELETE FROM note_list WHERE id = ?");
stmt2.bind(1, id);
stmt2.execute();
}
Note createNote(string username, ulong noteListId, string content) {
Database db = getDb(username);
Statement ordStmt = db.prepare("SELECT MAX(ordinality) + 1 FROM note WHERE note_list_id = ?");
ordStmt.bind(1, noteListId);
Nullable!uint ordResult = ordStmt.execute().oneValue!(Nullable!uint);
uint ordinality = 0;
if (!ordResult.isNull) ordinality = ordResult.get();
Statement insertStmt = db.prepare("INSERT INTO note (note_list_id, ordinality, content) VALUES (?, ?, ?)");
insertStmt.bind(1, noteListId);
insertStmt.bind(2, ordinality);
insertStmt.bind(3, content);
insertStmt.execute();
return Note(
db.lastInsertRowid(),
noteListId,
ordinality,
content
);
}
void deleteNote(string username, ulong id) {
Database db = getDb(username);
Statement stmt = db.prepare("DELETE FROM note WHERE id = ?");
stmt.bind(1, id);
stmt.execute();
}
private NoteList parseNoteList(Row row) {
NoteList list;
list.id = row["id"].as!ulong;
list.name = row["name"].as!string;
list.ordinality = row["ordinality"].as!uint;
list.description = row["description"].as!string;
return list;
}
private Note parseNote(Row row) {
Note note;
note.id = row["id"].as!ulong;
note.noteListId = row["note_list_id"].as!ulong;
note.ordinality = row["ordinality"].as!uint;
note.content = row["content"].as!string;
return note;
}
private Database getDb(string username) {
string dbPath = buildPath(USERS_DIR, username, DB_FILE);
if (!exists(dbPath)) initDb(dbPath);
return Database(dbPath);
}
private void initDb(string path) {
if (exists(path)) std.file.remove(path);
Database db = Database(path);
db.run(q"SQL
CREATE TABLE note_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
ordinality INTEGER NOT NULL DEFAULT 0,
description TEXT NULL
);
CREATE TABLE note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_list_id INTEGER NOT NULL,
ordinality INTEGER NOT NULL DEFAULT 0,
content TEXT NOT NULL
);
SQL"
);
db.close();
}
}

View File

@ -0,0 +1,56 @@
module lists;
import handy_httpd;
import std.json;
import auth;
import data;
void getNoteLists(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
NoteList[] lists = userDataSource.getLists(auth.user.username);
JSONValue listsArray = JSONValue(string[].init);
foreach (NoteList list; lists) {
listsArray.array ~= serializeList(list);
}
ctx.response.writeBodyString(listsArray.toString(), "application/json");
}
void createNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue requestBody = ctx.request.readBodyAsJson();
string listName = requestBody.object["name"].str;
string description = requestBody.object["description"].str;
NoteList list = userDataSource.createNoteList(auth.user.username, listName, description);
ctx.response.writeBodyString(serializeList(list).toString(), "application/json");
}
void deleteNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
}
private JSONValue serializeList(NoteList list) {
JSONValue listObj = JSONValue(string[string].init);
listObj.object["id"] = JSONValue(list.id);
listObj.object["name"] = JSONValue(list.name);
listObj.object["ordinality"] = JSONValue(list.ordinality);
listObj.object["description"] = JSONValue(list.description);
listObj.object["notes"] = JSONValue(string[].init);
foreach (Note note; list.notes) {
listObj.object["notes"].array ~= serializeNote(note);
}
return listObj;
}
private JSONValue serializeNote(Note note) {
JSONValue noteObj = JSONValue(string[string].init);
noteObj.object["id"] = JSONValue(note.id);
noteObj.object["ordinality"] = JSONValue(note.ordinality);
noteObj.object["noteListId"] = JSONValue(note.noteListId);
noteObj.object["content"] = JSONValue(note.content);
return noteObj;
}

View File

@ -0,0 +1,49 @@
import {API_URL} from "@/api/base";
export interface User {
username: string
email: string
}
export interface LoginInfo {
user: User
token: string
}
export interface LoginError {
message: string
}
interface LoginTokenResponse {
token: string
}
export async function login(username: string, password: string): Promise<LoginInfo> {
let response: Response | null = null
try {
response = await fetch(
API_URL + "/login",
{
method: "POST",
body: JSON.stringify({username: username, password: password})
}
)
} catch (error: any) {
throw {message: "Request failed: " + error.message}
}
if (response.ok) {
const content: LoginTokenResponse = await response.json()
const token = content.token
const userResponse = await fetch(API_URL + "/me", {
headers: {
"Authorization": "Bearer " + token
}
})
const user: User = await userResponse.json()
return {token: token, user: user}
} else if (response.status < 500) {
throw {message: "Invalid credentials."}
} else {
throw {message: "Server error. Try again later."}
}
}

View File

@ -0,0 +1 @@
export const API_URL = "http://localhost:8080"

View File

@ -0,0 +1,27 @@
import {API_URL} from "@/api/base";
export interface Note {
id: number
ordinality: number
content: string
noteListName: string
}
export interface NoteList {
name: string
ordinality: number
description: string
notes: Note[]
}
export async function getNoteLists(token: string): Promise<NoteList[]> {
const response = await fetch(API_URL + "/lists", {
headers: {"Authorization": "Bearer " + token}
})
if (response.ok) {
return await response.json()
} else {
console.error(response)
return []
}
}

View File

@ -1,13 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from "@/views/LoginView.vue";
import ListsView from "@/views/ListsView.vue";
import {useAuthStore} from "@/stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
path: "/",
name: "home-redirect",
redirect: to => {
return "login"
}
},
{
path: "/login",
name: "login",
component: LoginView
},
{
path: "/lists",
name: "lists",
component: ListsView,
beforeEnter: (to, from) => {
const authStore = useAuthStore()
if (!authStore.authenticated) return "login"
}
}
]
})

View File

@ -0,0 +1,25 @@
import {defineStore} from "pinia";
import {type Ref, ref} from "vue";
import type {User} from "@/api/auth";
export const useAuthStore = defineStore("auth", () => {
const authenticated: Ref<boolean> = ref(false)
const user: Ref<User | null> = ref(null)
const token: Ref<string | null> = ref(null)
function logIn(newToken: string, newUser: User) {
authenticated.value = true
user.value = newUser
token.value = newToken
}
function logOut() {
authenticated.value = false
user.value = null
token.value = null
}
return {authenticated, user, token, logIn, logOut}
})
export type AuthStore = typeof useAuthStore

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import {useAuthStore} from "@/stores/auth";
import {onMounted, ref, type Ref} from "vue";
import type {NoteList} from "@/api/lists";
import {getNoteLists} from "@/api/lists";
const authStore = useAuthStore()
const noteLists: Ref<NoteList[]> = ref([])
onMounted(async () => {
noteLists.value = await getNoteLists(authStore.token)
})
</script>
<template>
<h1>
Lists
</h1>
<p>
Here are your lists!
</p>
<div v-for="list in noteLists" :key="list.id">
<h3 v-text="list.name"></h3>
<ul>
<li v-for="note in list.notes" :key="note.id" v-text="note.content"></li>
</ul>
</div>
</template>
<style scoped>
</style>

View File

@ -1,11 +1,17 @@
<script setup lang="ts">
import {ref} from "vue";
import {login, type LoginError} from "@/api/auth";
import {useAuthStore} from "@/stores/auth";
import {useRouter} from "vue-router";
const loginModel = ref({
username: "",
password: ""
})
const authStore = useAuthStore()
const router = useRouter()
function resetLogin() {
loginModel.value.username = ""
loginModel.value.password = ""
@ -13,17 +19,11 @@ function resetLogin() {
async function doLogin() {
try {
const response = await fetch(
"http://localhost:8080/login",
{
method: "POST",
mode: "no-cors",
body: JSON.stringify(loginModel.value)
}
)
console.log(response.json())
} catch (error: AxiosError) {
console.error(error)
const info = await login(loginModel.value.username, loginModel.value.password)
authStore.logIn(info.token, info.user)
await router.push("lists")
} catch (error: any) {
console.error(error.message)
}
}
</script>