From 322c1046fa44bc34a45ddbb476ac958c9bdc7d3d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 30 Jun 2026 09:04:34 +0000 Subject: [PATCH] Add local username/password authentication - Server: new DB migration (7_local_auth.sql) adds local_username/local_password_hash columns - Server: add findLocalUser / createLocalUser to store, bcryptjs for password verification - Server: /api/auth advertises local: {} when config.auth.local.username+password are set - Server: POST /api/users handles local credential login, verifying username+password against config - Client: new Auth/Local.elm module with Method type and authPayload encoder - Client: Auth/Methods.elm gains local field, Login.elm renders username/password form - Config: auth.local now takes username + password (bcrypt hash) for single-account local login Co-Authored-By: Claude Sonnet 4.6 --- client/src/elm/ManyDecks.elm | 2 + client/src/elm/ManyDecks/Auth/Local.elm | 25 ++++++++ client/src/elm/ManyDecks/Auth/Methods.elm | 5 +- client/src/elm/ManyDecks/Model.elm | 2 + client/src/elm/ManyDecks/Pages/Login.elm | 59 ++++++++++++++----- .../elm/ManyDecks/Pages/Login/Messages.elm | 4 ++ server/package.json | 2 + server/src/sql/7_local_auth.sql | 10 ++++ server/src/ts/index.ts | 22 +++++++ server/src/ts/store.ts | 21 +++++++ 10 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 client/src/elm/ManyDecks/Auth/Local.elm create mode 100644 server/src/sql/7_local_auth.sql diff --git a/client/src/elm/ManyDecks.elm b/client/src/elm/ManyDecks.elm index 1095e52..e393d6f 100644 --- a/client/src/elm/ManyDecks.elm +++ b/client/src/elm/ManyDecks.elm @@ -65,6 +65,8 @@ init flags url key = , error = Nothing , auth = auth , authMethods = Nothing + , localUsernameField = "" + , localPasswordField = "" , usernameField = auth |> Maybe.map .name |> Maybe.withDefault "" , decks = Nothing , profileDeletionEnabled = False diff --git a/client/src/elm/ManyDecks/Auth/Local.elm b/client/src/elm/ManyDecks/Auth/Local.elm new file mode 100644 index 0000000..949616b --- /dev/null +++ b/client/src/elm/ManyDecks/Auth/Local.elm @@ -0,0 +1,25 @@ +module ManyDecks.Auth.Local exposing (..) + +import Json.Decode as Json +import Json.Encode + + +type alias Method = + {} + + +authPayload : String -> String -> Json.Encode.Value +authPayload username password = + [ ( "local" + , [ ( "username", username |> Json.Encode.string ) + , ( "password", password |> Json.Encode.string ) + ] + |> Json.Encode.object + ) + ] + |> Json.Encode.object + + +decode : Json.Decoder Method +decode = + Json.succeed {} diff --git a/client/src/elm/ManyDecks/Auth/Methods.elm b/client/src/elm/ManyDecks/Auth/Methods.elm index 7b78061..72bda1f 100644 --- a/client/src/elm/ManyDecks/Auth/Methods.elm +++ b/client/src/elm/ManyDecks/Auth/Methods.elm @@ -4,11 +4,13 @@ import Json.Decode as Json import Json.Decode.Pipeline as Json import ManyDecks.Auth.Google as Google import ManyDecks.Auth.Guest as Guest +import ManyDecks.Auth.Local as Local import ManyDecks.Auth.Twitch as Twitch type alias Methods = - { google : Maybe Google.Method + { local : Maybe Local.Method + , google : Maybe Google.Method , twitch : Maybe Twitch.Method , guest : Maybe Guest.Method } @@ -17,6 +19,7 @@ type alias Methods = decode : Json.Decoder Methods decode = Json.succeed Methods + |> Json.optional "local" (Local.decode |> Json.map Just) Nothing |> Json.optional "google" (Google.decode |> Json.map Just) Nothing |> Json.optional "twitch" (Twitch.decode |> Json.map Just) Nothing |> Json.optional "guest" (Guest.decode |> Json.map Just) Nothing diff --git a/client/src/elm/ManyDecks/Model.elm b/client/src/elm/ManyDecks/Model.elm index f5782a8..01ca4aa 100644 --- a/client/src/elm/ManyDecks/Model.elm +++ b/client/src/elm/ManyDecks/Model.elm @@ -32,6 +32,8 @@ type alias Model = -- Login , auth : Maybe Auth , authMethods : Maybe Auth.Methods + , localUsernameField : String + , localPasswordField : String -- Profile , usernameField : String diff --git a/client/src/elm/ManyDecks/Pages/Login.elm b/client/src/elm/ManyDecks/Pages/Login.elm index b5d0720..8055ba7 100644 --- a/client/src/elm/ManyDecks/Pages/Login.elm +++ b/client/src/elm/ManyDecks/Pages/Login.elm @@ -9,10 +9,12 @@ import FontAwesome.Icon as Icon import FontAwesome.Solid as Icon import Html exposing (Html) import Html.Attributes as HtmlA +import Html.Events as HtmlE import ManyDecks.Api as Api import ManyDecks.Auth as Auth import ManyDecks.Auth.Google as Google import ManyDecks.Auth.Guest as Guest +import ManyDecks.Auth.Local as Local import ManyDecks.Auth.Methods as Auth import ManyDecks.Auth.Twitch as Twitch import ManyDecks.Messages as Global @@ -44,6 +46,19 @@ update msg model = TryTwitchSignIn method -> ( model, method |> Twitch.requestUrl model.origin |> Navigation.load ) + TryLocalSignIn _ -> + ( model + , Api.signIn + (Local.authPayload model.localUsernameField model.localPasswordField) + (\isNewAccount auth -> SetAuth isNewAccount auth |> Global.LoginMsg) + ) + + SetLocalUsername username -> + ( { model | localUsernameField = username }, Cmd.none ) + + SetLocalPassword password -> + ( { model | localPasswordField = password }, Cmd.none ) + SetAuth isNewAccount auth -> let page = @@ -79,17 +94,7 @@ view model = , Html.a [ HtmlA.target "_blank", HtmlA.href Meta.massiveDecksUrl ] [ Html.text "Massive Decks" ] , Html.text "." ] - , Html.p [] - [ Html.text "This is a very early version, produced quickly in response to Cardcast's demise, there will " - , Html.text "likely be bugs. Please report any you find " - , Html.a [ HtmlA.target "_blank", HtmlA.href Meta.issuesUrl ] - [ Html.text "on GitHub" ] - ] - , Html.p [] - [ Html.text "Currently the data for this service is not backed up! Please keep local copies of your " - , Html.text "decks as well, just in case something goes wrong." - ] - , Html.div [ HtmlA.class "methods" ] (model.authMethods |> Maybe.map viewMethods |> Maybe.withDefault []) + , Html.div [ HtmlA.class "methods" ] (model.authMethods |> Maybe.map (viewMethods model) |> Maybe.withDefault []) ] , Html.div [ HtmlA.id "project-link" ] [ Html.a [ HtmlA.target "_blank", Meta.projectUrl |> HtmlA.href ] @@ -98,15 +103,41 @@ view model = ] -viewMethods : Auth.Methods -> List (Html Global.Msg) -viewMethods methods = +viewMethods : Model -> Auth.Methods -> List (Html Global.Msg) +viewMethods model methods = List.filterMap identity - [ methods.google |> Maybe.map google + [ methods.local |> Maybe.map (local model) + , methods.google |> Maybe.map google , methods.twitch |> Maybe.map twitch , methods.guest |> Maybe.map guest ] +local : Model -> Local.Method -> Html Global.Msg +local model method = + Html.div [ HtmlA.id "local-sign-in" ] + [ Html.input + [ HtmlA.type_ "text" + , HtmlA.placeholder "Username" + , HtmlA.value model.localUsernameField + , HtmlE.onInput (SetLocalUsername >> Global.LoginMsg) + ] + [] + , Html.input + [ HtmlA.type_ "password" + , HtmlA.placeholder "Password" + , HtmlA.value model.localPasswordField + , HtmlE.onInput (SetLocalPassword >> Global.LoginMsg) + ] + [] + , Button.view Button.Raised + Button.Padded + "Sign in" + (Icon.signInAlt |> Icon.viewIcon |> Just) + (method |> TryLocalSignIn |> Global.LoginMsg |> Just) + ] + + google : Google.Method -> Html Global.Msg google method = Html.div [ HtmlA.id "google-sign-in" ] diff --git a/client/src/elm/ManyDecks/Pages/Login/Messages.elm b/client/src/elm/ManyDecks/Pages/Login/Messages.elm index 7098252..a84c333 100644 --- a/client/src/elm/ManyDecks/Pages/Login/Messages.elm +++ b/client/src/elm/ManyDecks/Pages/Login/Messages.elm @@ -3,6 +3,7 @@ module ManyDecks.Pages.Login.Messages exposing (..) import ManyDecks.Auth exposing (Auth) import ManyDecks.Auth.Google as Google import ManyDecks.Auth.Guest as Guest +import ManyDecks.Auth.Local as Local import ManyDecks.Auth.Methods as Auth import ManyDecks.Auth.Twitch as Twitch @@ -13,5 +14,8 @@ type Msg | GoogleAuthResult String | TryGuestSignIn Guest.Method | TryTwitchSignIn Twitch.Method + | TryLocalSignIn Local.Method + | SetLocalUsername String + | SetLocalPassword String | SetAuth Bool Auth | SignOut diff --git a/server/package.json b/server/package.json index a233d09..784ddd5 100644 --- a/server/package.json +++ b/server/package.json @@ -41,6 +41,7 @@ "source-map-support": "^0.5.19", "unique-names-generator": "^4.3.1", "uuid": "^8.3.1", + "bcryptjs": "^2.4.3", "winston": "^3.3.3" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/jsonwebtoken": "^8.5.0", "@types/pg": "^7.14.6", "@types/source-map-support": "^0.5.3", + "@types/bcryptjs": "^2.4.2", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.4.0", "@typescript-eslint/parser": "^4.4.0", diff --git a/server/src/sql/7_local_auth.sql b/server/src/sql/7_local_auth.sql new file mode 100644 index 0000000..463ce62 --- /dev/null +++ b/server/src/sql/7_local_auth.sql @@ -0,0 +1,10 @@ +ALTER TABLE manydecks.users ADD COLUMN local_username TEXT, + ADD COLUMN local_password_hash TEXT, + ADD UNIQUE (local_username), + DROP CONSTRAINT can_login, + ADD CONSTRAINT can_login CHECK ( + google_id IS NOT NULL OR + twitch_id IS NOT NULL OR + is_guest OR + local_username IS NOT NULL + ); diff --git a/server/src/ts/index.ts b/server/src/ts/index.ts index fab8d1d..68a23b8 100644 --- a/server/src/ts/index.ts +++ b/server/src/ts/index.ts @@ -20,6 +20,7 @@ import * as util from "util"; import { default as Jwt } from "jsonwebtoken"; import { AuthFailure } from "./errors"; import { CreatedOrFoundUser } from "./store"; +import { default as Bcrypt } from "bcryptjs"; interface TwitchClaims { sub: string; @@ -94,6 +95,7 @@ const main = async (): Promise => { app.get("/api/auth", async (req, res) => { const auth = config.auth; res.json({ + ...(auth.local?.password !== undefined && auth.local?.username !== undefined ? { local: {} } : {}), ...(auth.guest !== undefined ? { guest: {} } : {}), ...(auth.google !== undefined ? { google: { id: auth.google.id } } : {}), ...(auth.twitch !== undefined ? { twitch: { id: auth.twitch.id } } : {}), @@ -159,6 +161,26 @@ const main = async (): Promise => { throw new Errors.AuthFailure(); } foundOrCreatedUser(await store.findOrCreateGuestUser()); + } else if (req.body.local) { + if (config.auth.local?.password === undefined || config.auth.local?.username === undefined) { + throw new Errors.AuthFailure(); + } + const { username, password } = req.body.local; + if (typeof username !== "string" || typeof password !== "string") { + throw new Errors.AuthFailure(); + } + if (username !== config.auth.local.username) { + throw new Errors.AuthFailure(); + } + if (!await Bcrypt.compare(password, config.auth.local.password)) { + throw new Errors.AuthFailure(); + } + const existing = await store.findLocalUser(username); + if (existing !== null) { + foundOrCreatedUser({ result: "Found", user: { id: existing.id, name: existing.name } }); + } else { + foundOrCreatedUser(await store.createLocalUser(username, config.auth.local.password)); + } } else { throw new Errors.AuthFailure(); } diff --git a/server/src/ts/store.ts b/server/src/ts/store.ts index 8d2b887..76151ee 100644 --- a/server/src/ts/store.ts +++ b/server/src/ts/store.ts @@ -124,6 +124,27 @@ export class Store { } }); + public findLocalUser: (username: string) => Promise<{ id: string; name: string; passwordHash: string } | null> = async (username) => + await this.withClient(async (client) => { + const result = await client.query( + `SELECT id, name, local_password_hash FROM manydecks.users WHERE local_username = $1;`, + [username] + ); + if (result.rowCount === 0) return null; + const [row] = result.rows; + return { id: row.id, name: row.name, passwordHash: row.local_password_hash }; + }); + + public createLocalUser: (username: string, passwordHash: string) => Promise = async (username, passwordHash) => + await this.withClient(async (client) => { + const newId = User.id(); + await client.query( + `INSERT INTO manydecks.users (id, name, local_username, local_password_hash) VALUES ($1, $2, $3, $4);`, + [newId, username, username, passwordHash] + ); + return { result: "Created", user: { id: newId, name: username } }; + }); + public findOrCreateGuestUser: () => Promise = async () => await this.withClient(async (client) => { const existingResult = await client.query(