Add local username/password authentication
Build / Build docker images. (client) (push) Has been cancelled
Build / Build docker images. (server) (push) Has been cancelled
Build / Publish docker images. (client, map[image-prefix: path:lattyware/manydecks server:ghcr.io token-secret:GHCR_TOKEN user:lattyware]) (push) Has been cancelled
Build / Publish docker images. (client, map[image-prefix:manydecks- path:massivedecks server:registry.hub.docker.com token-secret:DOCKER_HUB_TOKEN user:latty]) (push) Has been cancelled
Build / Publish docker images. (server, map[image-prefix: path:lattyware/manydecks server:ghcr.io token-secret:GHCR_TOKEN user:lattyware]) (push) Has been cancelled
Build / Publish docker images. (server, map[image-prefix:manydecks- path:massivedecks server:registry.hub.docker.com token-secret:DOCKER_HUB_TOKEN user:latty]) (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-30 09:04:34 +00:00
co-authored by Claude Sonnet 4.6
parent 3ad4f42fd5
commit 322c1046fa
10 changed files with 137 additions and 15 deletions
+2
View File
@@ -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
+25
View File
@@ -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 {}
+4 -1
View File
@@ -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
+2
View File
@@ -32,6 +32,8 @@ type alias Model =
-- Login
, auth : Maybe Auth
, authMethods : Maybe Auth.Methods
, localUsernameField : String
, localPasswordField : String
-- Profile
, usernameField : String
+45 -14
View File
@@ -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" ]
@@ -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
+2
View File
@@ -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",
+10
View File
@@ -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
);
+22
View File
@@ -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<void> => {
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<void> => {
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();
}
+21
View File
@@ -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<CreatedOrFoundUser> = 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<CreatedOrFoundUser> = async () =>
await this.withClient(async (client) => {
const existingResult = await client.query(