diff --git a/litelist-api/.gitignore b/litelist-api/.gitignore index 9d913cf..825b679 100644 --- a/litelist-api/.gitignore +++ b/litelist-api/.gitignore @@ -14,3 +14,5 @@ litelist-api-test-* *.o *.obj *.lst + +users/ diff --git a/litelist-api/dub.json b/litelist-api/dub.json index 8446401..cdcd3bb 100644 --- a/litelist-api/dub.json +++ b/litelist-api/dub.json @@ -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", diff --git a/litelist-api/dub.selections.json b/litelist-api/dub.selections.json index 55ed3d5..723af13 100644 --- a/litelist-api/dub.selections.json +++ b/litelist-api/dub.selections.json @@ -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" } diff --git a/litelist-api/source/app.d b/litelist-api/source/app.d index 977b421..7351874 100644 --- a/litelist-api/source/app.d +++ b/litelist-api/source/app.d @@ -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); } diff --git a/litelist-api/source/auth.d b/litelist-api/source/auth.d index de3ad6e..5e1df21 100644 --- a/litelist-api/source/auth.d +++ b/litelist-api/source/auth.d @@ -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"; - + AuthContextHolder.reset(); + if (!ctx.request.hasHeader(HEADER_NAME)) { + ctx.response.setStatus(HttpStatus.UNAUTHORIZED); + ctx.response.writeBodyString("Missing Authorization header."); + 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 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."); + } + + 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.get); + return true; +} + +class TokenFilter : HttpRequestFilter { 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; - } - 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; - } - string rawToken = authHeader[7 .. $]; - - // TODO: Validate token and fetch user. - User user = User("bleh", "bleh@example.com", "faef9834rfe"); - - AuthContextHolder.setContext(rawToken, user); - filterChain.doFilter(ctx); + if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx); } } diff --git a/litelist-api/source/data.d b/litelist-api/source/data.d new file mode 100644 index 0000000..207aa48 --- /dev/null +++ b/litelist-api/source/data.d @@ -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(); + } +} diff --git a/litelist-api/source/lists.d b/litelist-api/source/lists.d new file mode 100644 index 0000000..d619206 --- /dev/null +++ b/litelist-api/source/lists.d @@ -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; +} diff --git a/litelist-app/src/api/auth.ts b/litelist-app/src/api/auth.ts new file mode 100644 index 0000000..77c44a0 --- /dev/null +++ b/litelist-app/src/api/auth.ts @@ -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 { + 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."} + } +} diff --git a/litelist-app/src/api/base.ts b/litelist-app/src/api/base.ts new file mode 100644 index 0000000..3017938 --- /dev/null +++ b/litelist-app/src/api/base.ts @@ -0,0 +1 @@ +export const API_URL = "http://localhost:8080" diff --git a/litelist-app/src/api/lists.ts b/litelist-app/src/api/lists.ts new file mode 100644 index 0000000..a555928 --- /dev/null +++ b/litelist-app/src/api/lists.ts @@ -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 { + const response = await fetch(API_URL + "/lists", { + headers: {"Authorization": "Bearer " + token} + }) + if (response.ok) { + return await response.json() + } else { + console.error(response) + return [] + } +} \ No newline at end of file diff --git a/litelist-app/src/router/index.ts b/litelist-app/src/router/index.ts index 1316834..91d4dbc 100644 --- a/litelist-app/src/router/index.ts +++ b/litelist-app/src/router/index.ts @@ -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" + } } ] }) diff --git a/litelist-app/src/stores/auth.ts b/litelist-app/src/stores/auth.ts new file mode 100644 index 0000000..bcef4f1 --- /dev/null +++ b/litelist-app/src/stores/auth.ts @@ -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 = ref(false) + const user: Ref = ref(null) + const token: Ref = 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 diff --git a/litelist-app/src/views/ListsView.vue b/litelist-app/src/views/ListsView.vue new file mode 100644 index 0000000..568d8ba --- /dev/null +++ b/litelist-app/src/views/ListsView.vue @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/litelist-app/src/views/LoginView.vue b/litelist-app/src/views/LoginView.vue index 9b17095..9c1d566 100644 --- a/litelist-app/src/views/LoginView.vue +++ b/litelist-app/src/views/LoginView.vue @@ -1,11 +1,17 @@