feat(GRO-564): Better Auth Phase 2 Security Hardening
feat(GRO-564): Better Auth Phase 2 Security Hardening
This commit was merged in pull request #265.
This commit is contained in:
@@ -90,6 +90,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
max: 10,
|
||||||
|
window: 60,
|
||||||
|
storage: "database",
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -177,6 +183,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
secret: BETTER_AUTH_SECRET,
|
secret: BETTER_AUTH_SECRET,
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
max: 10,
|
||||||
|
window: 60,
|
||||||
|
storage: "database",
|
||||||
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, isNull, staff } from "@groombook/db";
|
import { eq, getDb, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -90,25 +90,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!fallbackRow) {
|
if (!fallbackRow) {
|
||||||
// Auto-link: staff record exists with matching email but no userId — link it now
|
|
||||||
if (jwt.email) {
|
|
||||||
const [linkedStaff] = await db
|
|
||||||
.select()
|
|
||||||
.from(staff)
|
|
||||||
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
|
|
||||||
if (linkedStaff) {
|
|
||||||
await db
|
|
||||||
.update(staff)
|
|
||||||
.set({ userId: jwt.sub })
|
|
||||||
.where(eq(staff.id, linkedStaff.id));
|
|
||||||
console.log(
|
|
||||||
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
|
|
||||||
);
|
|
||||||
c.set("staff", linkedStaff);
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const createStaffSchema = z.object({
|
|||||||
|
|
||||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||||
|
|
||||||
|
const linkUserSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
staffRouter.get("/me", async (c) => {
|
staffRouter.get("/me", async (c) => {
|
||||||
const staffRow = c.get("staff");
|
const staffRow = c.get("staff");
|
||||||
return c.json(staffRow);
|
return c.json(staffRow);
|
||||||
@@ -106,6 +110,32 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
|||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const targetId = c.req.param("id");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
|
||||||
|
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
||||||
|
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, targetId))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(staff)
|
||||||
|
.set({ userId: body.userId, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, targetId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
staffRouter.delete("/:id", async (c) => {
|
staffRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|||||||
+22
-2
@@ -1,4 +1,4 @@
|
|||||||
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
@@ -18,7 +18,7 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
|||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
import { useSession, signIn } from "./lib/auth-client.js";
|
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -181,6 +181,7 @@ const NAV_LINKS = [
|
|||||||
|
|
||||||
function AdminLayout() {
|
function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
|
|
||||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||||
@@ -261,6 +262,25 @@ function AdminLayout() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#4b5563",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
Reference in New Issue
Block a user