1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

Add authentication (using Kilovolt v6)

This commit is contained in:
Ash Keel 2021-11-21 22:36:48 +01:00
parent 27a7ede45e
commit 8e02ba6fb7
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
13 changed files with 505 additions and 12399 deletions

View file

@ -6,7 +6,9 @@ module.exports = {
'no-console': 0,
'import/extensions': 0,
'no-use-before-define': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-use-before-define': ['error'],
'@typescript-eslint/no-shadow': ['error'],
},
settings: {
'import/resolver': {

12612
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"@billjs/event-emitter": "^1.0.3",
"@reach/router": "^1.3.4",
"@reduxjs/toolkit": "^1.5.1",
"@strimertul/kilovolt-client": "^5.0.1",
"@strimertul/kilovolt-client": "^6.2.0",
"@types/node": "^15.0.2",
"@types/reach__router": "^1.3.7",
"@types/react": "^17.0.5",
@ -13,7 +13,6 @@
"@vitejs/plugin-react": "^1.0.9",
"bulma": "^0.9.2",
"i18next": "^20.6.1",
"parcel": "^2.0.1",
"pretty-ms": "^7.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -33,7 +32,6 @@
"last 1 Chrome version"
],
"devDependencies": {
"@parcel/transformer-sass": "^2.0.0-beta.2",
"@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0",
"eslint": "^7.26.0",

View file

@ -54,7 +54,8 @@
"server-bind": "HTTP server bind",
"static-content": "Static content",
"enable-static": "Enable static server",
"static-root-path": "Static content root path"
"static-root-path": "Static content root path",
"kv-password": "Kilovolt password"
},
"backend": {
"config": {
@ -226,5 +227,11 @@
"err-twitchbot-disabled": "Twitch bot must be enabled in order to use timers!",
"enable": "Enable timers"
}
},
"auth": {
"message": "Strimertül's database is protected by a password, please write it below to access the control panel. If the database has no password (for example, it was recently changed from having one to none), leave the field empty.",
"header": "Authorization needed",
"placeholder": "Password",
"button": "Authenticate"
}
}

View file

@ -8,8 +8,10 @@ import {
PayloadAction,
} from '@reduxjs/toolkit';
import KilovoltWS from '@strimertul/kilovolt-client';
import { kvError } from '@strimertul/kilovolt-client/lib/messages';
import {
APIState,
ConnectionStatus,
LoyaltyPointsEntry,
LoyaltyRedeem,
LoyaltyStorage,
@ -63,8 +65,11 @@ function makeModule<T>(
};
}
// eslint-disable-next-line import/no-mutable-exports, @typescript-eslint/ban-types
export let setupClientReconnect: AsyncThunk<void, KilovoltWS, {}>;
// eslint-disable-next-line @typescript-eslint/ban-types
let setupClientReconnect: AsyncThunk<void, KilovoltWS, {}>;
// eslint-disable-next-line @typescript-eslint/ban-types
let kvErrorReceived: AsyncThunk<void, kvError, {}>;
// Storage
const loyaltyPointsPrefix = 'loyalty/points/';
@ -76,8 +81,11 @@ const loyaltyRemoveRedeemKey = 'loyalty/@remove-redeem';
export const createWSClient = createAsyncThunk(
'api/createClient',
async (address: string, { dispatch }) => {
const client = new KilovoltWS(address);
async (options: { address: string; password?: string }, { dispatch }) => {
const client = new KilovoltWS(options.address, options.password);
client.on('error', (err) => {
dispatch(kvErrorReceived(err.data));
});
await client.wait();
dispatch(setupClientReconnect(client));
return client;
@ -233,7 +241,8 @@ const moduleChangeReducers = Object.fromEntries(
const initialState: APIState = {
client: null,
connected: false,
connectionStatus: ConnectionStatus.NotConnected,
kvError: null,
initialLoadComplete: false,
loyalty: {
users: null,
@ -264,8 +273,14 @@ const apiReducer = createSlice({
initialLoadCompleted(state) {
state.initialLoadComplete = true;
},
connectionStatusChanged(state, { payload }: PayloadAction<boolean>) {
state.connected = payload;
connectionStatusChanged(
state,
{ payload }: PayloadAction<ConnectionStatus>,
) {
state.connectionStatus = payload;
},
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
state.kvError = payload;
},
loyaltyUserPointsChanged(
state,
@ -279,7 +294,7 @@ const apiReducer = createSlice({
extraReducers: (builder) => {
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
state.client = payload;
state.connected = true;
state.connectionStatus = ConnectionStatus.Connected;
});
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
state.loyalty.users = payload;
@ -299,12 +314,38 @@ setupClientReconnect = createAsyncThunk(
console.info('Attempting reconnection');
client.reconnect();
}, 5000);
dispatch(apiReducer.actions.connectionStatusChanged(false));
dispatch(
apiReducer.actions.connectionStatusChanged(
ConnectionStatus.NotConnected,
),
);
});
client.on('open', () => {
dispatch(apiReducer.actions.connectionStatusChanged(true));
dispatch(
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
);
});
},
);
kvErrorReceived = createAsyncThunk(
'api/kvErrorReceived',
async (error: kvError, { dispatch }) => {
switch (error.error) {
case 'authentication required':
case 'authentication failed':
dispatch(
apiReducer.actions.connectionStatusChanged(
ConnectionStatus.AuthenticationNeeded,
),
);
break;
default:
// Unsupported error
dispatch(apiReducer.actions.kvErrorReceived(error));
console.error(error);
}
},
);
export default apiReducer;

View file

@ -1,6 +1,7 @@
/* eslint-disable camelcase */
import KilovoltWS from '@strimertul/kilovolt-client';
import { kvError } from '@strimertul/kilovolt-client/lib/messages';
interface ModuleConfig {
configured: boolean;
@ -13,6 +14,7 @@ interface ModuleConfig {
interface HTTPConfig {
bind: string;
enable_static_server: boolean;
kv_password: string;
path: string;
}
@ -109,9 +111,16 @@ export interface LoyaltyRedeem {
request_text: string;
}
export enum ConnectionStatus {
NotConnected,
AuthenticationNeeded,
Connected,
}
export interface APIState {
client: KilovoltWS;
connected: boolean;
connectionStatus: ConnectionStatus;
kvError: kvError;
initialLoadComplete: boolean;
loyalty: {
users: LoyaltyStorage;

View file

@ -1,5 +1,5 @@
import { Link, Redirect, Router, useLocation } from '@reach/router';
import React, { useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@ -23,6 +23,8 @@ import TwitchBotModulesPage from './pages/twitch/Modules';
import StulbeConfigPage from './pages/stulbe/Config';
import StulbeWebhooksPage from './pages/stulbe/Webhook';
import TwitchBotTimersPage from './pages/twitch/Timers';
import { ConnectionStatus } from '../store/api/types';
import Field from './components/Field';
interface RouteItem {
name?: string;
@ -59,27 +61,86 @@ const menu: RouteItem[] = [
},
];
function AuthModal(): React.ReactElement {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const inputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
});
const submit = () => {
localStorage.setItem('password', password);
window.location.reload();
};
return (
<div className="modal is-active">
<div className="modal-background"></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">{t('auth.header')}</p>
</header>
<section className="modal-card-body">
<Field>{t('auth.message')}</Field>
<Field>
<input
className="input"
type="password"
placeholder={t('auth.placeholder')}
value={password ?? ''}
ref={inputRef}
onChange={(ev) => setPassword(ev.target.value)}
onKeyUp={(ev) => {
if (ev.key === 'Enter' || ev.code === 'Enter') {
submit();
}
}}
/>
</Field>
</section>
<footer className="modal-card-foot">
<button className="button is-success" onClick={() => submit()}>
{t('auth.button')}
</button>
</footer>
</div>
</div>
);
}
export default function App(): React.ReactElement {
const loc = useLocation();
const { t } = useTranslation();
const client = useSelector((state: RootState) => state.api.client);
const connected = useSelector((state: RootState) => state.api.connected);
const connected = useSelector(
(state: RootState) => state.api.connectionStatus,
);
const dispatch = useDispatch();
// Create WS client
useEffect(() => {
if (!client) {
dispatch(
createWSClient(
process.env.NODE_ENV === 'development'
? 'ws://localhost:4337/ws'
: `ws://${loc.host}/ws`,
),
createWSClient({
address:
process.env.NODE_ENV === 'development'
? 'ws://localhost:4337/ws'
: `ws://${loc.host}/ws`,
password: localStorage.password,
}),
);
}
}, []);
if (connected === ConnectionStatus.AuthenticationNeeded) {
return <AuthModal />;
}
if (!client) {
return <div className="container">{t('system.loading')}</div>;
}
@ -108,7 +169,7 @@ export default function App(): React.ReactElement {
return (
<section className="main-content columns is-fullheight">
<section className="notifications">
{!connected ? (
{connected !== ConnectionStatus.Connected ? (
<div className="notification is-danger">
{t('system.connection-lost')}
</div>

View file

@ -1,9 +1,10 @@
import { RouteComponentProps } from '@reach/router';
import React, { useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useModule } from '../../lib/react-utils';
import apiReducer, { modules } from '../../store/api/reducer';
import Field from '../components/Field';
export default function HTTPPage(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -19,8 +20,7 @@ export default function HTTPPage(
return (
<>
<h1 className="title is-4">{t('http.header')}</h1>
<div className="field">
<label className="label">{t('http.server-bind')}</label>
<Field name={t('http.server-bind')}>
<p className="control">
<input
disabled={busy}
@ -38,9 +38,28 @@ export default function HTTPPage(
}
/>
</p>
</div>
<label className="label">{t('http.static-content')}</label>
<div className="field">
</Field>
<Field name={t('http.kv-password')}>
<p className="control">
<input
className="input"
type="password"
disabled={busy}
placeholder="None"
value={httpConfig?.kv_password ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...httpConfig,
kv_password: ev.target.value,
}),
)
}
/>
</p>
<p className="help">Leave empty to disable authentication</p>
</Field>
<Field name={t('http.static-content')}>
<label className="checkbox">
<input
type="checkbox"
@ -57,26 +76,27 @@ export default function HTTPPage(
/>{' '}
{t('http.enable-static')}
</label>
</div>
<div className="field">
<label className="label">{t('http.static-root-path')}</label>
<p className="control">
<input
className="input"
type="text"
disabled={busy || !active}
value={httpConfig?.path ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...httpConfig,
path: ev.target.value,
}),
)
}
/>
</p>
</div>
</Field>
{active && (
<Field name={t('http.static-root-path')}>
<p className="control">
<input
className="input"
type="text"
disabled={busy || !active}
value={httpConfig?.path ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...httpConfig,
path: ev.target.value,
}),
)
}
/>
</p>
</Field>
)}
<button
className="button"
onClick={() => {

6
go.mod
View file

@ -17,7 +17,7 @@ require (
github.com/nicklaw5/helix v1.25.0
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4
github.com/sirupsen/logrus v1.8.1
github.com/strimertul/kilovolt/v5 v5.0.1
github.com/strimertul/stulbe v0.4.3
github.com/strimertul/stulbe-client-go v0.4.0
github.com/strimertul/kilovolt/v6 v6.0.1
github.com/strimertul/stulbe v0.5.1
github.com/strimertul/stulbe-client-go v0.5.0
)

14
go.sum
View file

@ -90,6 +90,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLD
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/nicklaw5/helix v1.13.1/go.mod h1:XeeXY7oY5W+MVMu6wF4qGm8uvjZ1/Nss0FqprVkXKrg=
github.com/nicklaw5/helix v1.15.0/go.mod h1:XeeXY7oY5W+MVMu6wF4qGm8uvjZ1/Nss0FqprVkXKrg=
github.com/nicklaw5/helix v1.24.2/go.mod h1:XeeXY7oY5W+MVMu6wF4qGm8uvjZ1/Nss0FqprVkXKrg=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc h1:Ak86L+yDSOzKFa7WM5bf5itSOo1e3Xh8bm5YCMUXIjQ=
@ -129,16 +130,17 @@ github.com/strimertul/kilovolt-client-go/v2 v2.0.0/go.mod h1:y98V8uVMiJZhnBLtSVk
github.com/strimertul/kilovolt/v3 v3.0.0/go.mod h1:AgfPYRp+kffN64tcqCcQUZdpL/Dm5DGHIYRDm9t3E0Y=
github.com/strimertul/kilovolt/v4 v4.0.1 h1:81isohdSixVURO2+dZKKZBPw97HJmNN4/BXn6ADFoWM=
github.com/strimertul/kilovolt/v4 v4.0.1/go.mod h1:AO2ZFQtSB+AcjCw0RTkXjbM6XBAjhsXsrRq10BX95kw=
github.com/strimertul/kilovolt/v5 v5.0.1 h1:LHAVqb3SrXiew3loTpYuPdz16Nl8/aTReBYj56xwF7I=
github.com/strimertul/kilovolt/v5 v5.0.1/go.mod h1:HxfnnlEGhY6p+Im9U7pso07HEV+cXEsJH7uFTM7c6uE=
github.com/strimertul/kilovolt/v6 v6.0.0/go.mod h1:O5Rwg8o66omRP4O3qInBKreW9jILZz2MEq4MuotzAXw=
github.com/strimertul/kilovolt/v6 v6.0.1 h1:CNiaRsh0wWtZ3yQlz+9RZAtJr3m14ODIa9WQt7NyQ40=
github.com/strimertul/kilovolt/v6 v6.0.1/go.mod h1:O5Rwg8o66omRP4O3qInBKreW9jILZz2MEq4MuotzAXw=
github.com/strimertul/strimertul v1.3.0/go.mod h1:1pSe9zVWF4BYt56ii1Hg+xTxvtfqfvT4FQ7bYffWsUA=
github.com/strimertul/stulbe v0.2.5/go.mod h1:0AsY4OVf1dNCwOn9s7KySuAxJ85w88pXeostu1n9E7w=
github.com/strimertul/stulbe v0.4.0/go.mod h1:Pb0UQCKdyES7UKSKm2i2g9parkgXSJAFeMH/LSOSbgQ=
github.com/strimertul/stulbe v0.4.3 h1:apDTvFaChCoMxUokc1Y51Wn43hWyf0qqMNkptnlIj/c=
github.com/strimertul/stulbe v0.4.3/go.mod h1:Pb0UQCKdyES7UKSKm2i2g9parkgXSJAFeMH/LSOSbgQ=
github.com/strimertul/stulbe v0.5.1 h1:CY3s/Vv6I5YkmLcFE3OsY+ysjw+7HywcMcW4EuE6Ejo=
github.com/strimertul/stulbe v0.5.1/go.mod h1:fj25VOPKQH2IYcrmjr+bkWffpTiKd7O3ixbe8ZiwbzQ=
github.com/strimertul/stulbe-client-go v0.1.0/go.mod h1:KtfuDhxCHZ9DCFHnrBOHqb2Pu9zoj+EqA8ZRIUqLD/w=
github.com/strimertul/stulbe-client-go v0.4.0 h1:9DEHnbjU452qFQaK9ilrzydEirpVxwVeiCz7T0kZxEk=
github.com/strimertul/stulbe-client-go v0.4.0/go.mod h1:Ssz1mEEWt4y7yj6Dm4aCaEV651Tzz9M+suzhWzC2QqQ=
github.com/strimertul/stulbe-client-go v0.5.0 h1:tci0uZ4AiNXb4izCjxL2/qAh1kmR8D74ltZJojgP7Ik=
github.com/strimertul/stulbe-client-go v0.5.0/go.mod h1:Ssz1mEEWt4y7yj6Dm4aCaEV651Tzz9M+suzhWzC2QqQ=
github.com/twitchyliquid64/golang-asm v0.15.0/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=

View file

@ -13,8 +13,6 @@ import (
"github.com/strimertul/strimertul/modules/http"
kv "github.com/strimertul/kilovolt/v5"
"github.com/strimertul/strimertul/database"
"github.com/strimertul/strimertul/modules"
"github.com/strimertul/strimertul/modules/loyalty"
@ -118,11 +116,6 @@ func main() {
fmt.Printf("It appears this is your first time running %s! Please go to http://%s and make sure to configure anything you want!\n\n", AppTitle, DefaultBind)
}
// Initialize KV (required)
hub, err := kv.NewHub(db.Client(), wrapLogger("kv"))
failOnError(err, "Could not initialize kilovolt hub")
go hub.Run()
// Get Stulbe config, if enabled
var stulbeManager *stulbe.Manager = nil
if moduleConfig.EnableStulbe {
@ -185,7 +178,6 @@ func main() {
fedir, _ := fs.Sub(frontend, "frontend/dist")
httpServer.SetFrontend(fedir)
httpServer.SetHub(hub)
go func() {
time.Sleep(time.Second) // THIS IS STUPID

View file

@ -6,4 +6,5 @@ type ServerConfig struct {
Bind string `json:"bind"`
EnableStaticServer bool `json:"enable_static_server"`
Path string `json:"path"`
KVPassword string `json:"kv_password"`
}

View file

@ -7,7 +7,7 @@ import (
"io/fs"
"net/http"
kv "github.com/strimertul/kilovolt/v5"
kv "github.com/strimertul/kilovolt/v6"
"github.com/sirupsen/logrus"
@ -35,18 +35,25 @@ func NewServer(db *database.DB, log logrus.FieldLogger) (*Server, error) {
server: &http.Server{},
}
err := db.GetJSON(ServerConfigKey, &server.Config)
if err != nil {
return nil, err
}
return server, err
server.hub, err = kv.NewHub(db.Client(), kv.HubOptions{
Password: server.Config.KVPassword,
}, log.WithField("module", "kv"))
if err != nil {
return nil, err
}
go server.hub.Run()
return server, nil
}
func (s *Server) SetFrontend(files fs.FS) {
s.frontend = files
}
func (s *Server) SetHub(hub *kv.Hub) {
s.hub = hub
}
func (s *Server) makeMux() *http.ServeMux {
mux := http.NewServeMux()
@ -74,11 +81,19 @@ func (s *Server) Listen() error {
for _, pair := range changed {
if pair.Key == ServerConfigKey {
oldBind := s.Config.Bind
oldPassword := s.Config.KVPassword
err := s.db.GetJSON(ServerConfigKey, &s.Config)
if err != nil {
return err
}
s.mux = s.makeMux()
// Restart hub if password changed
if oldPassword != s.Config.KVPassword {
s.hub.SetOptions(kv.HubOptions{
Password: s.Config.KVPassword,
})
}
// Restart server if bind changed
if oldBind != s.Config.Bind {
restart.Set(true)
err = s.server.Shutdown(context.Background())