Some enhancement of the lockout mechanism

pull/1158/head
Zef Hemel 2024-11-15 16:50:50 +01:00
parent 046a0df868
commit 51fc5952bc
8 changed files with 57 additions and 52 deletions

View File

@ -12,6 +12,14 @@ import { runPlug } from "../cmd/plug_run.ts";
import { PrefixedKvPrimitives } from "$lib/data/prefixed_kv_primitives.ts"; import { PrefixedKvPrimitives } from "$lib/data/prefixed_kv_primitives.ts";
import { sleep } from "$lib/async.ts"; import { sleep } from "$lib/async.ts";
export type AuthOptions = {
authToken?: string;
user: string;
pass: string;
lockoutTime: number;
lockoutLimit: number;
};
export async function serveCommand( export async function serveCommand(
options: { options: {
hostname?: string; hostname?: string;
@ -63,10 +71,29 @@ export async function serveCommand(
const userAuth = options.user ?? Deno.env.get("SB_USER"); const userAuth = options.user ?? Deno.env.get("SB_USER");
let userCredentials: { user: string; pass: string } | undefined; let userCredentials: AuthOptions | undefined;
if (userAuth) { if (userAuth) {
const [user, pass] = userAuth.split(":"); const [user, pass] = userAuth.split(":");
userCredentials = { user, pass }; userCredentials = {
user,
pass,
// 10 failed login attempts in 1 minute
lockoutLimit: 10,
lockoutTime: 60,
};
// Override lockout settings if they are set in the environment
if (Deno.env.get("SB_LOCKOUT_LIMIT")) {
userCredentials.lockoutLimit = Number(Deno.env.get("SB_LOCKOUT_LIMIT"));
}
if (Deno.env.get("SB_LOCKOUT_TIME")) {
userCredentials.lockoutTime = Number(Deno.env.get("SB_LOCKOUT_TIME"));
}
if (Deno.env.get("SB_AUTH_TOKEN")) {
userCredentials.authToken = Deno.env.get("SB_AUTH_TOKEN");
}
console.log(
`User authentication enabled for user "${user}" with lockout limit ${userCredentials.lockoutLimit} and lockout time ${userCredentials.lockoutTime}s`,
);
} }
const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local"; const backendConfig = Deno.env.get("SB_SHELL_BACKEND") || "local";
@ -125,7 +152,6 @@ export async function serveCommand(
certFile: options.cert, certFile: options.cert,
auth: userCredentials, auth: userCredentials,
authToken: Deno.env.get("SB_AUTH_TOKEN"),
syncOnly, syncOnly,
readOnly, readOnly,
shellBackend: backendConfig, shellBackend: backendConfig,

View File

@ -21,6 +21,7 @@ import {
} from "@silverbulletmd/silverbullet/lib/page_ref"; } from "@silverbulletmd/silverbullet/lib/page_ref";
import { base64Encode } from "$lib/crypto.ts"; import { base64Encode } from "$lib/crypto.ts";
import { LockoutTimer } from "./lockout.ts"; import { LockoutTimer } from "./lockout.ts";
import type { AuthOptions } from "../cmd/server.ts";
const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week const authenticationExpirySeconds = 60 * 60 * 24 * 7; // 1 week
@ -32,11 +33,8 @@ export type ServerOptions = {
baseKvPrimitives: KvPrimitives; baseKvPrimitives: KvPrimitives;
certFile?: string; certFile?: string;
keyFile?: string; keyFile?: string;
// Enable username/password/token auth
// Enable username/password auth auth?: AuthOptions;
auth?: { user: string; pass: string };
// Additional API auth token
authToken?: string;
pagesPath: string; pagesPath: string;
shellBackend: string; shellBackend: string;
syncOnly: boolean; syncOnly: boolean;
@ -315,7 +313,15 @@ export class HttpServer {
"/logo.png", "/logo.png",
"/.auth", "/.auth",
]; ];
const lockoutTimer = new LockoutTimer();
// Since we're a single user app, we can use a single lockout timer to prevent brute force attacks
const lockoutTimer = this.options.auth?.lockoutLimit
? new LockoutTimer(
// Turn into ms
this.options.auth.lockoutTime * 1000,
this.options.auth.lockoutLimit!,
)
: new LockoutTimer(0, 0); // disabled
// TODO: This should probably be a POST request // TODO: This should probably be a POST request
this.app.get("/.logout", (c) => { this.app.get("/.logout", (c) => {
@ -397,7 +403,7 @@ export class HttpServer {
// Check auth // Check auth
this.app.use("*", async (c, next) => { this.app.use("*", async (c, next) => {
const req = c.req; const req = c.req;
if (!this.spaceServer.auth && !this.spaceServer.authToken) { if (!this.spaceServer.auth) {
// Auth disabled in this config, skip // Auth disabled in this config, skip
return next(); return next();
} }
@ -414,12 +420,12 @@ export class HttpServer {
if (!excludedPaths.includes(url.pathname)) { if (!excludedPaths.includes(url.pathname)) {
const authCookie = getCookie(c, authCookieName(host)); const authCookie = getCookie(c, authCookieName(host));
if (!authCookie && this.spaceServer.authToken) { if (!authCookie && this.spaceServer.auth?.authToken) {
// Attempt Bearer Authorization based authentication // Attempt Bearer Authorization based authentication
const authHeader = req.header("Authorization"); const authHeader = req.header("Authorization");
if (authHeader && authHeader.startsWith("Bearer ")) { if (authHeader && authHeader.startsWith("Bearer ")) {
const authToken = authHeader.slice("Bearer ".length); const authToken = authHeader.slice("Bearer ".length);
if (authToken === this.spaceServer.authToken) { if (authToken === this.spaceServer.auth.authToken) {
// All good, let's proceed // All good, let's proceed
this.refreshLogin(c, host); this.refreshLogin(c, host);
return next(); return next();

View File

@ -5,35 +5,14 @@ import { FakeTime } from "@std/testing/time";
const lockoutTime = 60000; const lockoutTime = 60000;
const lockoutLimit = 10; const lockoutLimit = 10;
Deno.test("Lockout - failed login rate limiter", async () => { Deno.test("Lockout - failed login rate limiter", () => {
const envLockoutTime = Deno.env.get("SB_LOCKOUT_TIME_MS") || "";
const envLockoutLimit = Deno.env.get("SB_LOCKOUT_LIMIT") || "";
using time = new FakeTime(); using time = new FakeTime();
testDisabled(new LockoutTimer(NaN, lockoutLimit), "by period"); testDisabled(new LockoutTimer(NaN, lockoutLimit), "by period");
testDisabled(new LockoutTimer(lockoutTime, NaN), "by limit"); testDisabled(new LockoutTimer(lockoutTime, NaN), "by limit");
Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime)); testLockout(new LockoutTimer(lockoutTime, 10), "explicit params");
Deno.env.set("SB_LOCKOUT_LIMIT", ""); testLockoutPerMS(new LockoutTimer(lockoutTime, 10), "explicit params");
testDisabled(new LockoutTimer(lockoutTime, NaN), "by env period");
Deno.env.set("SB_LOCKOUT_TIME_MS", "");
Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit));
testDisabled(new LockoutTimer(lockoutTime, NaN), "by env limit");
Deno.env.set("SB_LOCKOUT_TIME_MS", "");
Deno.env.set("SB_LOCKOUT_LIMIT", "");
await testLockout(new LockoutTimer(lockoutTime, 10), "explicit params");
await testLockoutPerMS(new LockoutTimer(lockoutTime, 10), "explicit params");
Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime));
Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit));
await testLockout(new LockoutTimer(), "params from env");
await testLockoutPerMS(new LockoutTimer(), "params from env");
Deno.env.set("SB_LOCKOUT_TIME_MS", envLockoutTime);
Deno.env.set("SB_LOCKOUT_LIMIT", envLockoutLimit);
function testDisabled(timer: LockoutTimer, txt: string) { function testDisabled(timer: LockoutTimer, txt: string) {
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {

View File

@ -11,8 +11,8 @@ export class LockoutTimer {
disabled: boolean; disabled: boolean;
constructor( constructor(
countPeriodMs: number = Number(Deno.env.get("SB_LOCKOUT_TIME_MS")) || NaN, countPeriodMs: number,
limit: number = Number(Deno.env.get("SB_LOCKOUT_LIMIT")) || NaN, limit: number,
) { ) {
this.disabled = isNaN(countPeriodMs) || isNaN(limit) || countPeriodMs < 1 || this.disabled = isNaN(countPeriodMs) || isNaN(limit) || countPeriodMs < 1 ||
limit < 1; limit < 1;

View File

@ -25,12 +25,12 @@ import {
defaultConfig, defaultConfig,
} from "../type/config.ts"; } from "../type/config.ts";
import type { ServerOptions } from "./http_server.ts"; import type { ServerOptions } from "./http_server.ts";
import type { AuthOptions } from "../cmd/server.ts";
// Equivalent of Client on the server // Equivalent of Client on the server
export class SpaceServer implements ConfigContainer { export class SpaceServer implements ConfigContainer {
public pagesPath: string; public pagesPath: string;
auth?: { user: string; pass: string }; auth?: AuthOptions;
authToken?: string;
hostname: string; hostname: string;
config: Config; config: Config;
@ -54,7 +54,6 @@ export class SpaceServer implements ConfigContainer {
this.pagesPath = options.pagesPath; this.pagesPath = options.pagesPath;
this.hostname = options.hostname; this.hostname = options.hostname;
this.auth = options.auth; this.auth = options.auth;
this.authToken = options.authToken;
this.syncOnly = options.syncOnly; this.syncOnly = options.syncOnly;
this.readOnly = options.readOnly; this.readOnly = options.readOnly;
this.config = defaultConfig; this.config = defaultConfig;
@ -115,7 +114,7 @@ export class SpaceServer implements ConfigContainer {
if (this.auth) { if (this.auth) {
// Initialize JWT issuer // Initialize JWT issuer
await this.jwtIssuer.init( await this.jwtIssuer.init(
JSON.stringify({ auth: this.auth, authToken: this.authToken }), JSON.stringify({ auth: this.auth }),
); );
} }

View File

@ -1,6 +1,6 @@
SilverBullet supports simple authentication for a single user. SilverBullet supports simple authentication for a single user.
By simply passing the `--user` flag with a username:password combination, you enable authentication for a single user. For instance: By passing the `--user` flag with a username:password combination, you enable authentication for a single user. For instance:
```shell ```shell
silverbullet --user pete:1234 . silverbullet --user pete:1234 .
@ -8,10 +8,4 @@ silverbullet --user pete:1234 .
Will let `pete` authenticate with password `1234`. Will let `pete` authenticate with password `1234`.
Alternatively, the same information can be passed in via the `SB_USER` environment variable, e.g. Authentication can also be configured via environment variables (which offer a bit more flexibility), see [[Install/Configuration#Authentication]].
```shell
SB_USER=pete:1234 silverbullet .
```
This is especially convenient when deploying using Docker

View File

@ -4,9 +4,8 @@ An attempt at documenting the changes/new features introduced in each release.
## Edge ## Edge
_These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._ _These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._
* Nothing yet since 0.10.0. Stay tuned!
[[CHANGELOG]] * (Security) Implemented a lockout mechanism after a number of failed login attempts for [[Authentication]] (configured via [[Install/Configuration#Authentication]]) (by [Peter Weston](https://github.com/silverbulletmd/silverbullet/pull/1152))
## 0.10.1 ## 0.10.1
This is a “major” release primarily because of the underlying migration to rely on Deno 2 and a bunch of foundational work thats not really leveraged yet. Stay tuned for more. This is a “major” release primarily because of the underlying migration to rely on Deno 2 and a bunch of foundational work thats not really leveraged yet. Stay tuned for more.

View File

@ -11,6 +11,8 @@ SilverBullet supports basic authentication for a single user.
* `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”. * `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”.
* `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[API]] (useful for [[Sync]] and remote HTTP storage backends). * `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[API]] (useful for [[Sync]] and remote HTTP storage backends).
* `SB_LOCKOUT_LIMIT`: Specifies the number of failed login attempt before locking the user out (for a `SB_LOCKOUT_TIME` specified amount of seconds), defaults to `10`
* `SB_LOCKOUT_TIME`: Specifies the amount of time (in seconds) a client will be blocked until attempting to log back in.
# Storage # Storage
SilverBullet supports multiple storage backends for keeping your [[Spaces]] content. SilverBullet supports multiple storage backends for keeping your [[Spaces]] content.