From 39846e59b90e058af4a20e252dcd763985974702 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Tue, 18 Aug 2026 16:11:51 +0700 Subject: [PATCH] feat: add api for authentication --- .dev.vars.example | 3 + .env.example | 3 + README.md | 1 + docs/authentication.md | 8 ++ docs/openapi.yaml | 110 +++++++++++++++++++++ src/auth-api.ts | 137 ++++++++++++++++++++++++++ src/aws-signature.ts | 4 +- src/index.ts | 5 + src/types.ts | 1 + test/auth.test.ts | 214 +++++++++++++++++++++++++++++++++++++++++ vitest.config.mts | 1 + 11 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 src/auth-api.ts create mode 100644 test/auth.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 0eb49c9..8018200 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -17,6 +17,9 @@ ALLOWED_BUCKETS=assets # Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD. PUBLIC_READ_BUCKETS= +# Plaintext password for dashboard management API authentication. +DASHBOARD_PASSWORD=replace-with-dashboard-password + # Durable Object multipart uploads and ETag result behavior. ALLOW_MULTIPART=true ETAG_STYLE=md5 diff --git a/.env.example b/.env.example index 44ae38d..8aea324 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ ALLOWED_BUCKETS=assets # Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD. PUBLIC_READ_BUCKETS= +# Plaintext password for dashboard management API authentication +DASHBOARD_PASSWORD=replace-with-dashboard-password + # Durable Object multipart uploads and ETag result behavior ALLOW_MULTIPART=true ETAG_STYLE=md5 diff --git a/README.md b/README.md index 2171475..21d69e2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ https://developers.cloudflare.com/workers/configuration/secrets/#via-the-dashboa | `SECRET_KEY` | A secure secret key used by the S3 client. | | `REGION` | The region used by the S3 client. | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. | +| `DASHBOARD_PASSWORD` | *(Optional)* Plaintext password for dashboard management API authentication. | | `ALLOWED_BUCKETS` | Set the buckets allowed, separated by `,`. A directory with the bucket name will be created directly under Google Drive. | | `PUBLIC_READ_BUCKETS` | *(Optional)* Buckets that allow unauthenticated GET/HEAD access without signature, separated by `,`. Write operations (PUT/POST/DELETE) still require authentication. Must be a subset of `ALLOWED_BUCKETS`. | | `CORS_ALLOWED_ORIGINS` | *(Optional)* Comma-separated exact browser origins, or `*`. Unset emits no CORS headers. | diff --git a/docs/authentication.md b/docs/authentication.md index 99662f9..963ca83 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -36,4 +36,12 @@ Payload hashes are **not** verified. Browser and BFF clients should use `x-amz-c Buckets listed in `PUBLIC_READ_BUCKETS` permit unsigned `GET` and `HEAD` requests. All write operations still require valid Signature V4 authentication. `PUBLIC_READ_BUCKETS` must be a subset of `ALLOWED_BUCKETS`. +## Dashboard login API + +The Worker provides management API endpoints under `/auth/*` for the management dashboard (`s3-drive-storage-manage`). These endpoints **do not** use AWS Signature V4: + +- `POST /auth/login` — Verifies `{ passwordHash }` against `DASHBOARD_PASSWORD` (SHA-256 compared in constant time). Returns `{ token, expiresIn }` on success (opaque 256-bit base64url token with a 12-hour TTL stored in `AUTH_KV`). Rate-limited to 5 failed attempts per IP within 15 minutes. +- `GET /auth/session` — Validates the session token supplied in `Authorization: Bearer `. Returns `{ valid: true }` if valid, or `401` if expired/invalid. +- `POST /auth/logout` — Revokes the session token supplied in `Authorization: Bearer `. Returns `204 No Content`. + For a tested signing reference, see the `signed()` helper in [`test/s3.test.ts`](../test/s3.test.ts). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b277897..c077739 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -17,7 +17,87 @@ tags: - name: Objects - name: Buckets - name: Multipart uploads + - name: Dashboard Auth paths: + /auth/login: + post: + tags: [Dashboard Auth] + operationId: authLogin + summary: Login to dashboard + description: Authenticate with SHA-256 hashed dashboard password. + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LoginRequest' } + responses: + '200': + description: Login successful. + content: + application/json: + schema: { $ref: '#/components/schemas/LoginResponse' } + '400': + description: Invalid request. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '401': + description: Invalid password. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '429': + description: Too many failed attempts. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '503': + description: Dashboard authentication not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + /auth/session: + get: + tags: [Dashboard Auth] + operationId: authSession + summary: Verify session token + security: + - bearerAuth: [] + responses: + '200': + description: Session is valid. + content: + application/json: + schema: + type: object + properties: + valid: { type: boolean, example: true } + '401': + description: Invalid or expired session. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '503': + description: Dashboard authentication not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + /auth/logout: + post: + tags: [Dashboard Auth] + operationId: authLogout + summary: Logout of dashboard session + security: + - bearerAuth: [] + responses: + '204': + description: Successfully logged out. + '503': + description: Dashboard authentication not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } /{bucket}: parameters: - $ref: '#/components/parameters/Bucket' @@ -236,6 +316,10 @@ components: in: header name: Authorization description: AWS Signature Version 4 header authentication or equivalent `X-Amz-*` presigned query parameters. + bearerAuth: + type: http + scheme: bearer + description: Session token returned from `/auth/login`. parameters: Bucket: name: bucket @@ -299,6 +383,32 @@ components: description: The method/path combination is unsupported. content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } } schemas: + LoginRequest: + type: object + required: [passwordHash] + properties: + passwordHash: + type: string + description: SHA-256 hash of the dashboard password in lowercase hex. + example: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f + LoginResponse: + type: object + required: [token, expiresIn] + properties: + token: + type: string + description: Session token. + expiresIn: + type: integer + description: Token TTL in seconds. + example: 43200 + AuthError: + type: object + required: [message] + properties: + message: + type: string + example: Invalid password S3Error: type: object xml: { name: Error } diff --git a/src/auth-api.ts b/src/auth-api.ts new file mode 100644 index 0000000..0ff4912 --- /dev/null +++ b/src/auth-api.ts @@ -0,0 +1,137 @@ +import { constantTimeEqual, sha256 } from "./aws-signature"; +import type { Env } from "./types"; + +export const AUTH_PATH_PREFIX = "auth"; +const SESSION_PREFIX = "session:"; +const LOGIN_FAIL_PREFIX = "login-fail:"; +const SESSION_TTL_SECONDS = 12 * 60 * 60; +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_SECONDS = 900; + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); +} + +function generateToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export async function handleAuth(request: Request, env: Env, subPath: string): Promise { + try { + if (!env.DASHBOARD_PASSWORD) { + return jsonResponse({ message: "Dashboard authentication is not configured" }, 503); + } + + if (subPath === "login") { + if (request.method !== "POST") { + return jsonResponse({ message: "Method Not Allowed" }, 405); + } + + let body: { passwordHash?: unknown }; + try { + body = await request.json(); + } catch { + return jsonResponse({ message: "Invalid JSON body" }, 400); + } + + if (!body || typeof body.passwordHash !== "string" || !body.passwordHash) { + return jsonResponse({ message: "Missing or invalid passwordHash" }, 400); + } + + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const failKey = `${LOGIN_FAIL_PREFIX}${ip}`; + const failedAttemptsStr = await env.AUTH_KV.get(failKey); + const failedAttempts = failedAttemptsStr ? parseInt(failedAttemptsStr, 10) : 0; + + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + return jsonResponse({ message: "Too many failed attempts. Please try again later." }, 429, { "Retry-After": LOCKOUT_SECONDS.toString() }); + } + + const expectedHash = await sha256(env.DASHBOARD_PASSWORD); + const providedHash = body.passwordHash.trim().toLowerCase(); + + if (!constantTimeEqual(expectedHash, providedHash)) { + const nextAttempts = failedAttempts + 1; + await env.AUTH_KV.put(failKey, nextAttempts.toString(), { expirationTtl: LOCKOUT_SECONDS }); + return jsonResponse({ message: "Invalid password" }, 401); + } + + // Login successful + await env.AUTH_KV.delete(failKey); + const token = generateToken(); + const tokenHash = await sha256(token); + const sessionData = { + createdAt: new Date().toISOString(), + ip, + }; + await env.AUTH_KV.put(`${SESSION_PREFIX}${tokenHash}`, JSON.stringify(sessionData), { + expirationTtl: SESSION_TTL_SECONDS, + }); + + return jsonResponse({ token, expiresIn: SESSION_TTL_SECONDS }, 200); + } + + if (subPath === "session") { + if (request.method !== "GET") { + return jsonResponse({ message: "Method Not Allowed" }, 405); + } + + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return jsonResponse({ message: "Session expired or invalid" }, 401); + } + + const token = authHeader.slice("Bearer ".length).trim(); + if (!token) { + return jsonResponse({ message: "Session expired or invalid" }, 401); + } + + const tokenHash = await sha256(token); + const session = await env.AUTH_KV.get(`${SESSION_PREFIX}${tokenHash}`); + if (!session) { + return jsonResponse({ message: "Session expired or invalid" }, 401); + } + + return jsonResponse({ valid: true }, 200); + } + + if (subPath === "logout") { + if (request.method !== "POST") { + return jsonResponse({ message: "Method Not Allowed" }, 405); + } + + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice("Bearer ".length).trim(); + if (token) { + const tokenHash = await sha256(token); + await env.AUTH_KV.delete(`${SESSION_PREFIX}${tokenHash}`); + } + } + + return new Response(null, { status: 204 }); + } + + return jsonResponse({ message: "Not Found" }, 404); + } catch (error) { + console.error( + JSON.stringify({ + message: "auth handler error", + error: error instanceof Error ? error.message : String(error), + method: request.method, + subPath, + }), + ); + return jsonResponse({ message: "Internal server error" }, 500); + } +} diff --git a/src/aws-signature.ts b/src/aws-signature.ts index 0d217c2..ae38d31 100644 --- a/src/aws-signature.ts +++ b/src/aws-signature.ts @@ -22,7 +22,7 @@ async function hmacSha256(key: string | ArrayBuffer, data: string): Promise { +export async function sha256(data: string): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)); return bufToHex(hash); } @@ -121,7 +121,7 @@ function parseAmzDate(datetime: string): number | null { return timestamp; } -function constantTimeEqual(left: string, right: string): boolean { +export function constantTimeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false; let mismatch = 0; for (let index = 0; index < left.length; index++) { diff --git a/src/index.ts b/src/index.ts index 2e8220e..d5fcd52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { AUTH_PATH_PREFIX, handleAuth } from "./auth-api"; import { verifySignature } from "./aws-signature"; import { isAllowedBucket, isPublicReadBucket } from "./bucket-access"; import { preflightResponse, withCors } from "./cors"; @@ -21,6 +22,10 @@ export default { } const pathParts = url.pathname.split("/").filter(Boolean); + if (pathParts[0] === AUTH_PATH_PREFIX && !isAllowedBucket(AUTH_PATH_PREFIX, env)) { + return withCors(await handleAuth(request, env, pathParts.slice(1).join("/")), request, env); + } + const bucket = pathParts[0] || ""; const objectKey = pathParts.slice(1).join("/"); const resource = url.pathname || "/"; diff --git a/src/types.ts b/src/types.ts index a30ec2b..f35d89f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ export interface Env { AUTH_KV: KVNamespace; FOLDER_CACHE: KVNamespace; MPU: DurableObjectNamespace; + DASHBOARD_PASSWORD?: string; ALLOWED_BUCKETS?: string; PUBLIC_READ_BUCKETS?: string; ALLOW_MULTIPART?: string; diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..c0aef60 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; + +import { sha256 } from "../src/aws-signature"; +import worker from "../src/index"; +import type { Env } from "../src/types"; + +import { env } from "cloudflare:test"; + +const ENV = env as unknown as Env; +const ENDPOINT = "https://s3-api.example.com"; +const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext; + +describe("Dashboard authentication API routes", () => { + it("returns 503 when DASHBOARD_PASSWORD is not configured", async () => { + const withoutPassword = { ...ENV, DASHBOARD_PASSWORD: undefined }; + const res = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passwordHash: "any" }), + }), + withoutPassword, + CTX, + ); + expect(res.status).toBe(503); + const data = (await res.json()) as { message: string }; + expect(data.message).toBe("Dashboard authentication is not configured"); + }); + + it("handles login with invalid body or missing passwordHash", async () => { + const res1 = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }), + ENV, + CTX, + ); + expect(res1.status).toBe(400); + + const res2 = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + ENV, + CTX, + ); + expect(res2.status).toBe(400); + }); + + it("rejects invalid password and succeeds with valid password hash", async () => { + const correctHash = await sha256("test-dashboard-password"); + const wrongHash = await sha256("wrong-password"); + + // Wrong password + const wrongRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.1" }, + body: JSON.stringify({ passwordHash: wrongHash }), + }), + ENV, + CTX, + ); + expect(wrongRes.status).toBe(401); + const wrongData = (await wrongRes.json()) as { message: string }; + expect(wrongData.message).toBe("Invalid password"); + + // Correct password + const correctRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.1" }, + body: JSON.stringify({ passwordHash: correctHash.toUpperCase() }), // test case-insensitivity + }), + ENV, + CTX, + ); + expect(correctRes.status).toBe(200); + const correctData = (await correctRes.json()) as { token: string; expiresIn: number }; + expect(correctData.token).toBeTruthy(); + expect(correctData.expiresIn).toBe(43200); + }); + + it("verifies valid session and rejects invalid/expired token", async () => { + const correctHash = await sha256("test-dashboard-password"); + + const loginRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.2" }, + body: JSON.stringify({ passwordHash: correctHash }), + }), + ENV, + CTX, + ); + expect(loginRes.status).toBe(200); + const { token } = (await loginRes.json()) as { token: string }; + + // Valid session check + const validRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/session`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + expect(validRes.status).toBe(200); + const validData = (await validRes.json()) as { valid: boolean }; + expect(validData.valid).toBe(true); + + // Invalid token + const invalidRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/session`, { + headers: { Authorization: "Bearer bogus-token" }, + }), + ENV, + CTX, + ); + expect(invalidRes.status).toBe(401); + + // Missing header + const missingRes = await worker.fetch(new Request(`${ENDPOINT}/auth/session`), ENV, CTX); + expect(missingRes.status).toBe(401); + }); + + it("supports logout and revokes session", async () => { + const correctHash = await sha256("test-dashboard-password"); + + const loginRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.3" }, + body: JSON.stringify({ passwordHash: correctHash }), + }), + ENV, + CTX, + ); + const { token } = (await loginRes.json()) as { token: string }; + + // Logout + const logoutRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/logout`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + expect(logoutRes.status).toBe(204); + + // Session check should now fail + const sessionRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/session`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + expect(sessionRes.status).toBe(401); + }); + + it("rate-limits after MAX_FAILED_ATTEMPTS", async () => { + const testIp = "10.0.0.99"; + const wrongHash = await sha256("wrong-password"); + + for (let i = 0; i < 5; i++) { + const res = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": testIp }, + body: JSON.stringify({ passwordHash: wrongHash }), + }), + ENV, + CTX, + ); + expect(res.status).toBe(401); + } + + // 6th attempt should be blocked + const blockedRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": testIp }, + body: JSON.stringify({ passwordHash: wrongHash }), + }), + ENV, + CTX, + ); + expect(blockedRes.status).toBe(429); + expect(blockedRes.headers.get("Retry-After")).toBe("900"); + }); + + it("does not claim /auth when auth is a configured bucket", async () => { + const withAuthBucket = { ...ENV, ALLOWED_BUCKETS: "test-bucket,auth" }; + const response = await worker.fetch(new Request(`${ENDPOINT}/auth/login`), withAuthBucket, CTX); + expect(response.status).toBe(403); + expect(await response.text()).toContain("SignatureDoesNotMatch"); + }); + + it("emits CORS headers for allowed origin", async () => { + const res = await worker.fetch( + new Request(`${ENDPOINT}/auth/session`, { + headers: { Origin: "http://localhost:5173" }, + }), + ENV, + CTX, + ); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173"); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 0ff7d65..11e2fc4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,6 +13,7 @@ export default defineConfig({ GOOGLE_CLIENT_ID: "test-client-id", GOOGLE_CLIENT_SECRET: "test-client-secret", GOOGLE_REFRESH_TOKEN: "test-refresh-token", + DASHBOARD_PASSWORD: "test-dashboard-password", ALLOWED_BUCKETS: "test-bucket,empty-bucket,my-bucket", ALLOW_MULTIPART: "true", ETAG_STYLE: "md5",