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
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:
co-authored by
Claude Sonnet 4.6
parent
3ad4f42fd5
commit
322c1046fa
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -32,6 +32,8 @@ type alias Model =
|
||||
-- Login
|
||||
, auth : Maybe Auth
|
||||
, authMethods : Maybe Auth.Methods
|
||||
, localUsernameField : String
|
||||
, localPasswordField : String
|
||||
|
||||
-- Profile
|
||||
, usernameField : String
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user