@atercates/claude-deck 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/auth/login/route.ts +57 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/api/auth/session/route.ts +29 -0
- package/app/api/auth/setup/route.ts +67 -0
- package/app/login/page.tsx +192 -0
- package/app/setup/page.tsx +279 -0
- package/lib/auth/index.ts +15 -0
- package/lib/auth/password.ts +14 -0
- package/lib/auth/rate-limit.ts +40 -0
- package/lib/auth/session.ts +83 -0
- package/lib/auth/totp.ts +36 -0
- package/lib/db/queries.ts +64 -0
- package/lib/db/schema.ts +19 -0
- package/lib/db/types.ts +16 -0
- package/package.json +5 -1
- package/server.ts +19 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { queries } from "@/lib/db";
|
|
3
|
+
import {
|
|
4
|
+
verifyPassword,
|
|
5
|
+
verifyTotpCode,
|
|
6
|
+
createSession,
|
|
7
|
+
buildSessionCookie,
|
|
8
|
+
checkRateLimit,
|
|
9
|
+
} from "@/lib/auth";
|
|
10
|
+
|
|
11
|
+
export async function POST(request: NextRequest) {
|
|
12
|
+
const ip =
|
|
13
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
14
|
+
request.headers.get("x-real-ip") ||
|
|
15
|
+
"unknown";
|
|
16
|
+
|
|
17
|
+
const rateCheck = checkRateLimit(ip);
|
|
18
|
+
if (!rateCheck.allowed) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: "Too many login attempts. Try again later." },
|
|
21
|
+
{
|
|
22
|
+
status: 429,
|
|
23
|
+
headers: { "Retry-After": String(rateCheck.retryAfterSeconds) },
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const body = await request.json();
|
|
29
|
+
const { username, password, totpCode } = body;
|
|
30
|
+
|
|
31
|
+
const INVALID = NextResponse.json(
|
|
32
|
+
{ error: "Invalid credentials" },
|
|
33
|
+
{ status: 401 }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (!username || !password) return INVALID;
|
|
37
|
+
|
|
38
|
+
const user = queries.getUserByUsername(username);
|
|
39
|
+
if (!user) return INVALID;
|
|
40
|
+
|
|
41
|
+
const validPassword = await verifyPassword(password, user.password_hash);
|
|
42
|
+
if (!validPassword) return INVALID;
|
|
43
|
+
|
|
44
|
+
if (user.totp_secret) {
|
|
45
|
+
if (!totpCode) {
|
|
46
|
+
return NextResponse.json({ requiresTotp: true });
|
|
47
|
+
}
|
|
48
|
+
if (!verifyTotpCode(user.totp_secret, totpCode)) {
|
|
49
|
+
return INVALID;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { token } = createSession(user.id);
|
|
54
|
+
const response = NextResponse.json({ ok: true });
|
|
55
|
+
response.headers.set("Set-Cookie", buildSessionCookie(token));
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { deleteSession, buildClearCookie, COOKIE_NAME } from "@/lib/auth";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: NextRequest) {
|
|
5
|
+
const token = request.cookies.get(COOKIE_NAME)?.value;
|
|
6
|
+
if (token) {
|
|
7
|
+
deleteSession(token);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const response = NextResponse.json({ ok: true });
|
|
11
|
+
response.headers.set("Set-Cookie", buildClearCookie());
|
|
12
|
+
return response;
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
validateSession,
|
|
4
|
+
renewSession,
|
|
5
|
+
COOKIE_NAME,
|
|
6
|
+
hasUsers,
|
|
7
|
+
} from "@/lib/auth";
|
|
8
|
+
|
|
9
|
+
export async function GET(request: NextRequest) {
|
|
10
|
+
if (!hasUsers()) {
|
|
11
|
+
return NextResponse.json({ authenticated: false, needsSetup: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const token = request.cookies.get(COOKIE_NAME)?.value;
|
|
15
|
+
if (!token) {
|
|
16
|
+
return NextResponse.json({ authenticated: false }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const user = validateSession(token);
|
|
20
|
+
if (!user) {
|
|
21
|
+
return NextResponse.json({ authenticated: false }, { status: 401 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
renewSession(token);
|
|
25
|
+
return NextResponse.json({
|
|
26
|
+
authenticated: true,
|
|
27
|
+
username: user.username,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { queries } from "@/lib/db";
|
|
4
|
+
import {
|
|
5
|
+
hashPassword,
|
|
6
|
+
verifyTotpCode,
|
|
7
|
+
createSession,
|
|
8
|
+
buildSessionCookie,
|
|
9
|
+
hasUsers,
|
|
10
|
+
} from "@/lib/auth";
|
|
11
|
+
|
|
12
|
+
export async function POST(request: NextRequest) {
|
|
13
|
+
if (hasUsers()) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: "Setup already completed" },
|
|
16
|
+
{ status: 403 }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const body = await request.json();
|
|
21
|
+
const { username, password, totpSecret, totpCode } = body;
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
!username ||
|
|
25
|
+
typeof username !== "string" ||
|
|
26
|
+
username.length < 3 ||
|
|
27
|
+
username.length > 32 ||
|
|
28
|
+
!/^[a-zA-Z0-9_]+$/.test(username)
|
|
29
|
+
) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{
|
|
32
|
+
error:
|
|
33
|
+
"Username must be 3-32 characters, alphanumeric and underscore only",
|
|
34
|
+
},
|
|
35
|
+
{ status: 400 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!password || typeof password !== "string" || password.length < 8) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Password must be at least 8 characters" },
|
|
42
|
+
{ status: 400 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (totpSecret) {
|
|
47
|
+
if (!totpCode || !verifyTotpCode(totpSecret, totpCode)) {
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
error:
|
|
51
|
+
"Invalid TOTP code. Scan the QR code again and enter the current code.",
|
|
52
|
+
},
|
|
53
|
+
{ status: 400 }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const id = randomBytes(16).toString("hex");
|
|
59
|
+
const passwordHash = await hashPassword(password);
|
|
60
|
+
|
|
61
|
+
queries.createUser(id, username, passwordHash, totpSecret || null);
|
|
62
|
+
|
|
63
|
+
const { token } = createSession(id);
|
|
64
|
+
const response = NextResponse.json({ ok: true });
|
|
65
|
+
response.headers.set("Set-Cookie", buildSessionCookie(token));
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Loader2, Lock, Eye, EyeOff } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export default function LoginPage() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const [username, setUsername] = useState("");
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [totpCode, setTotpCode] = useState("");
|
|
14
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
15
|
+
const [error, setError] = useState("");
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [step, setStep] = useState<"credentials" | "totp">("credentials");
|
|
18
|
+
const totpInputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (step === "totp" && totpInputRef.current) {
|
|
22
|
+
totpInputRef.current.focus();
|
|
23
|
+
}
|
|
24
|
+
}, [step]);
|
|
25
|
+
|
|
26
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setError("");
|
|
29
|
+
setLoading(true);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch("/api/auth/login", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
username,
|
|
37
|
+
password,
|
|
38
|
+
...(step === "totp" ? { totpCode } : {}),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
|
|
44
|
+
if (res.status === 429) {
|
|
45
|
+
setError(
|
|
46
|
+
`Too many attempts. Try again in ${data.retryAfterSeconds || 60}s.`
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (data.requiresTotp) {
|
|
52
|
+
setStep("totp");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
setError(data.error || "Invalid credentials");
|
|
58
|
+
if (step === "totp") setTotpCode("");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
router.push("/");
|
|
63
|
+
router.refresh();
|
|
64
|
+
} catch {
|
|
65
|
+
setError("Connection error");
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (step === "totp" && totpCode.length === 6) {
|
|
73
|
+
const form = document.getElementById("login-form") as HTMLFormElement;
|
|
74
|
+
form?.requestSubmit();
|
|
75
|
+
}
|
|
76
|
+
}, [totpCode, step]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="bg-background flex min-h-screen items-center justify-center p-4">
|
|
80
|
+
<div className="border-border bg-card w-full max-w-sm rounded-xl border p-8 shadow-lg">
|
|
81
|
+
<div className="mb-8 text-center">
|
|
82
|
+
<div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
83
|
+
<Lock className="h-6 w-6" />
|
|
84
|
+
</div>
|
|
85
|
+
<h1 className="text-foreground text-2xl font-semibold">ClaudeDeck</h1>
|
|
86
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
87
|
+
{step === "credentials"
|
|
88
|
+
? "Sign in to continue"
|
|
89
|
+
: "Enter your 2FA code"}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<form id="login-form" onSubmit={handleSubmit} className="space-y-4">
|
|
94
|
+
{step === "credentials" ? (
|
|
95
|
+
<>
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<label
|
|
98
|
+
htmlFor="username"
|
|
99
|
+
className="text-foreground text-sm font-medium"
|
|
100
|
+
>
|
|
101
|
+
Username
|
|
102
|
+
</label>
|
|
103
|
+
<Input
|
|
104
|
+
id="username"
|
|
105
|
+
type="text"
|
|
106
|
+
value={username}
|
|
107
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
108
|
+
autoComplete="username"
|
|
109
|
+
autoFocus
|
|
110
|
+
required
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
<label
|
|
115
|
+
htmlFor="password"
|
|
116
|
+
className="text-foreground text-sm font-medium"
|
|
117
|
+
>
|
|
118
|
+
Password
|
|
119
|
+
</label>
|
|
120
|
+
<div className="relative">
|
|
121
|
+
<Input
|
|
122
|
+
id="password"
|
|
123
|
+
type={showPassword ? "text" : "password"}
|
|
124
|
+
value={password}
|
|
125
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
126
|
+
autoComplete="current-password"
|
|
127
|
+
required
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
132
|
+
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
|
133
|
+
tabIndex={-1}
|
|
134
|
+
>
|
|
135
|
+
{showPassword ? (
|
|
136
|
+
<EyeOff className="h-4 w-4" />
|
|
137
|
+
) : (
|
|
138
|
+
<Eye className="h-4 w-4" />
|
|
139
|
+
)}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
<label
|
|
147
|
+
htmlFor="totp"
|
|
148
|
+
className="text-foreground text-sm font-medium"
|
|
149
|
+
>
|
|
150
|
+
Authentication code
|
|
151
|
+
</label>
|
|
152
|
+
<Input
|
|
153
|
+
ref={totpInputRef}
|
|
154
|
+
id="totp"
|
|
155
|
+
type="text"
|
|
156
|
+
inputMode="numeric"
|
|
157
|
+
pattern="[0-9]*"
|
|
158
|
+
maxLength={6}
|
|
159
|
+
value={totpCode}
|
|
160
|
+
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
|
161
|
+
placeholder="000000"
|
|
162
|
+
className="text-center text-2xl tracking-[0.5em]"
|
|
163
|
+
autoComplete="one-time-code"
|
|
164
|
+
required
|
|
165
|
+
/>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => {
|
|
169
|
+
setStep("credentials");
|
|
170
|
+
setTotpCode("");
|
|
171
|
+
setError("");
|
|
172
|
+
}}
|
|
173
|
+
className="text-muted-foreground hover:text-foreground text-xs underline"
|
|
174
|
+
>
|
|
175
|
+
Back to login
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<p className="text-destructive text-center text-sm">{error}</p>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
185
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
186
|
+
{step === "credentials" ? "Sign in" : "Verify"}
|
|
187
|
+
</Button>
|
|
188
|
+
</form>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Switch } from "@/components/ui/switch";
|
|
8
|
+
import { Loader2, Shield, Eye, EyeOff } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export default function SetupPage() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const [username, setUsername] = useState("");
|
|
13
|
+
const [password, setPassword] = useState("");
|
|
14
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
15
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
16
|
+
const [enableTotp, setEnableTotp] = useState(false);
|
|
17
|
+
const [totpSecret, setTotpSecret] = useState("");
|
|
18
|
+
const [totpCode, setTotpCode] = useState("");
|
|
19
|
+
const [qrDataUrl, setQrDataUrl] = useState("");
|
|
20
|
+
const [error, setError] = useState("");
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
fetch("/api/auth/session").then(async (res) => {
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
if (!data.needsSetup) {
|
|
27
|
+
router.push(data.authenticated ? "/" : "/login");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}, [router]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (enableTotp && !totpSecret && username.length >= 3) {
|
|
34
|
+
generateTotp();
|
|
35
|
+
}
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit generateTotp and totpSecret to avoid regenerating secret on every render
|
|
37
|
+
}, [enableTotp, username]);
|
|
38
|
+
|
|
39
|
+
const generateTotp = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const { TOTP, Secret } = await import("otpauth");
|
|
42
|
+
const secret = new Secret({ size: 20 });
|
|
43
|
+
const totp = new TOTP({
|
|
44
|
+
issuer: "ClaudeDeck",
|
|
45
|
+
label: username,
|
|
46
|
+
algorithm: "SHA1",
|
|
47
|
+
digits: 6,
|
|
48
|
+
period: 30,
|
|
49
|
+
secret,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const uri = totp.toString();
|
|
53
|
+
setTotpSecret(secret.base32);
|
|
54
|
+
|
|
55
|
+
const QRCode = await import("qrcode");
|
|
56
|
+
const dataUrl = await QRCode.toDataURL(uri, {
|
|
57
|
+
width: 200,
|
|
58
|
+
margin: 2,
|
|
59
|
+
color: { dark: "#ffffff", light: "#00000000" },
|
|
60
|
+
});
|
|
61
|
+
setQrDataUrl(dataUrl);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error("Failed to generate TOTP:", err);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setError("");
|
|
70
|
+
|
|
71
|
+
if (password !== confirmPassword) {
|
|
72
|
+
setError("Passwords do not match");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (password.length < 8) {
|
|
77
|
+
setError("Password must be at least 8 characters");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (enableTotp && totpCode.length !== 6) {
|
|
82
|
+
setError("Enter the 6-digit code from your authenticator app");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setLoading(true);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch("/api/auth/setup", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
username,
|
|
94
|
+
password,
|
|
95
|
+
...(enableTotp ? { totpSecret, totpCode } : {}),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const data = await res.json();
|
|
100
|
+
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
setError(data.error || "Setup failed");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
router.push("/");
|
|
107
|
+
router.refresh();
|
|
108
|
+
} catch {
|
|
109
|
+
setError("Connection error");
|
|
110
|
+
} finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="bg-background flex min-h-screen items-center justify-center p-4">
|
|
117
|
+
<div className="border-border bg-card w-full max-w-md rounded-xl border p-8 shadow-lg">
|
|
118
|
+
<div className="mb-8 text-center">
|
|
119
|
+
<div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
120
|
+
<Shield className="h-6 w-6" />
|
|
121
|
+
</div>
|
|
122
|
+
<h1 className="text-foreground text-2xl font-semibold">
|
|
123
|
+
Welcome to ClaudeDeck
|
|
124
|
+
</h1>
|
|
125
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
126
|
+
Create your account to get started
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
131
|
+
<div className="space-y-2">
|
|
132
|
+
<label
|
|
133
|
+
htmlFor="username"
|
|
134
|
+
className="text-foreground text-sm font-medium"
|
|
135
|
+
>
|
|
136
|
+
Username
|
|
137
|
+
</label>
|
|
138
|
+
<Input
|
|
139
|
+
id="username"
|
|
140
|
+
type="text"
|
|
141
|
+
value={username}
|
|
142
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
143
|
+
placeholder="admin"
|
|
144
|
+
autoComplete="username"
|
|
145
|
+
autoFocus
|
|
146
|
+
required
|
|
147
|
+
minLength={3}
|
|
148
|
+
maxLength={32}
|
|
149
|
+
pattern="[a-zA-Z0-9_]+"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="space-y-2">
|
|
154
|
+
<label
|
|
155
|
+
htmlFor="password"
|
|
156
|
+
className="text-foreground text-sm font-medium"
|
|
157
|
+
>
|
|
158
|
+
Password
|
|
159
|
+
</label>
|
|
160
|
+
<div className="relative">
|
|
161
|
+
<Input
|
|
162
|
+
id="password"
|
|
163
|
+
type={showPassword ? "text" : "password"}
|
|
164
|
+
value={password}
|
|
165
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
166
|
+
autoComplete="new-password"
|
|
167
|
+
required
|
|
168
|
+
minLength={8}
|
|
169
|
+
/>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
173
|
+
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
|
174
|
+
tabIndex={-1}
|
|
175
|
+
>
|
|
176
|
+
{showPassword ? (
|
|
177
|
+
<EyeOff className="h-4 w-4" />
|
|
178
|
+
) : (
|
|
179
|
+
<Eye className="h-4 w-4" />
|
|
180
|
+
)}
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
<label
|
|
187
|
+
htmlFor="confirmPassword"
|
|
188
|
+
className="text-foreground text-sm font-medium"
|
|
189
|
+
>
|
|
190
|
+
Confirm password
|
|
191
|
+
</label>
|
|
192
|
+
<Input
|
|
193
|
+
id="confirmPassword"
|
|
194
|
+
type={showPassword ? "text" : "password"}
|
|
195
|
+
value={confirmPassword}
|
|
196
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
197
|
+
autoComplete="new-password"
|
|
198
|
+
required
|
|
199
|
+
minLength={8}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
|
204
|
+
<div>
|
|
205
|
+
<p className="text-foreground text-sm font-medium">
|
|
206
|
+
Two-factor authentication
|
|
207
|
+
</p>
|
|
208
|
+
<p className="text-muted-foreground text-xs">
|
|
209
|
+
Secure your account with TOTP
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
<Switch checked={enableTotp} onCheckedChange={setEnableTotp} />
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{enableTotp && qrDataUrl && (
|
|
216
|
+
<div className="border-border space-y-3 rounded-lg border p-4">
|
|
217
|
+
<p className="text-foreground text-center text-sm font-medium">
|
|
218
|
+
Scan with your authenticator app
|
|
219
|
+
</p>
|
|
220
|
+
<div className="flex justify-center">
|
|
221
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- base64 data URL, next/image not applicable */}
|
|
222
|
+
<img
|
|
223
|
+
src={qrDataUrl}
|
|
224
|
+
alt="TOTP QR Code"
|
|
225
|
+
className="h-[200px] w-[200px]"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="space-y-1">
|
|
229
|
+
<p className="text-muted-foreground text-center text-xs">
|
|
230
|
+
Or enter manually:
|
|
231
|
+
</p>
|
|
232
|
+
<code className="bg-muted text-foreground block rounded p-2 text-center font-mono text-xs break-all">
|
|
233
|
+
{totpSecret}
|
|
234
|
+
</code>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="space-y-2">
|
|
237
|
+
<label
|
|
238
|
+
htmlFor="totpVerify"
|
|
239
|
+
className="text-foreground text-sm font-medium"
|
|
240
|
+
>
|
|
241
|
+
Verification code
|
|
242
|
+
</label>
|
|
243
|
+
<Input
|
|
244
|
+
id="totpVerify"
|
|
245
|
+
type="text"
|
|
246
|
+
inputMode="numeric"
|
|
247
|
+
pattern="[0-9]*"
|
|
248
|
+
maxLength={6}
|
|
249
|
+
value={totpCode}
|
|
250
|
+
onChange={(e) =>
|
|
251
|
+
setTotpCode(e.target.value.replace(/\D/g, ""))
|
|
252
|
+
}
|
|
253
|
+
placeholder="000000"
|
|
254
|
+
className="text-center text-lg tracking-[0.3em]"
|
|
255
|
+
autoComplete="one-time-code"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{enableTotp && !qrDataUrl && username.length < 3 && (
|
|
262
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
263
|
+
Enter a username (3+ characters) to generate the QR code
|
|
264
|
+
</p>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{error && (
|
|
268
|
+
<p className="text-destructive text-center text-sm">{error}</p>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
272
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
273
|
+
Create account
|
|
274
|
+
</Button>
|
|
275
|
+
</form>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { hashPassword, verifyPassword } from "./password";
|
|
2
|
+
export { generateTotpSecret, verifyTotpCode } from "./totp";
|
|
3
|
+
export {
|
|
4
|
+
createSession,
|
|
5
|
+
validateSession,
|
|
6
|
+
renewSession,
|
|
7
|
+
deleteSession,
|
|
8
|
+
cleanupExpiredSessions,
|
|
9
|
+
COOKIE_NAME,
|
|
10
|
+
buildSessionCookie,
|
|
11
|
+
buildClearCookie,
|
|
12
|
+
parseCookies,
|
|
13
|
+
hasUsers,
|
|
14
|
+
} from "./session";
|
|
15
|
+
export { checkRateLimit } from "./rate-limit";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
|
|
3
|
+
const BCRYPT_COST = 12;
|
|
4
|
+
|
|
5
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
6
|
+
return bcrypt.hash(password, BCRYPT_COST);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function verifyPassword(
|
|
10
|
+
password: string,
|
|
11
|
+
hash: string
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
return bcrypt.compare(password, hash);
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const MAX_ATTEMPTS = 5;
|
|
2
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
interface RateLimitEntry {
|
|
5
|
+
count: number;
|
|
6
|
+
resetAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const attempts = new Map<string, RateLimitEntry>();
|
|
10
|
+
|
|
11
|
+
setInterval(
|
|
12
|
+
() => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [ip, entry] of attempts) {
|
|
15
|
+
if (entry.resetAt < now) attempts.delete(ip);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
5 * 60 * 1000
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export function checkRateLimit(ip: string): {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
retryAfterSeconds?: number;
|
|
24
|
+
} {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const entry = attempts.get(ip);
|
|
27
|
+
|
|
28
|
+
if (!entry || entry.resetAt < now) {
|
|
29
|
+
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
30
|
+
return { allowed: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (entry.count >= MAX_ATTEMPTS) {
|
|
34
|
+
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
|
35
|
+
return { allowed: false, retryAfterSeconds };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
entry.count++;
|
|
39
|
+
return { allowed: true };
|
|
40
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { queries } from "@/lib/db";
|
|
3
|
+
import type { User } from "@/lib/db";
|
|
4
|
+
|
|
5
|
+
const SESSION_DURATION_DAYS = 30;
|
|
6
|
+
const SESSION_TOKEN_BYTES = 32;
|
|
7
|
+
|
|
8
|
+
export function createSession(userId: string): {
|
|
9
|
+
token: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
} {
|
|
12
|
+
const id = randomBytes(16).toString("hex");
|
|
13
|
+
const token = randomBytes(SESSION_TOKEN_BYTES).toString("hex");
|
|
14
|
+
const expiresAt = new Date(
|
|
15
|
+
Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
|
|
16
|
+
).toISOString();
|
|
17
|
+
|
|
18
|
+
queries.createAuthSession(id, token, userId, expiresAt);
|
|
19
|
+
|
|
20
|
+
return { token, expiresAt };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateSession(token: string): User | null {
|
|
24
|
+
if (!token || token.length !== SESSION_TOKEN_BYTES * 2) return null;
|
|
25
|
+
|
|
26
|
+
const session = queries.getAuthSessionByToken(token);
|
|
27
|
+
if (!session) return null;
|
|
28
|
+
|
|
29
|
+
if (new Date(session.expires_at) < new Date()) {
|
|
30
|
+
queries.deleteAuthSession(token);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const user = queries.getUserById(session.user_id);
|
|
35
|
+
if (!user) {
|
|
36
|
+
queries.deleteAuthSession(token);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return user;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renewSession(token: string): void {
|
|
44
|
+
const expiresAt = new Date(
|
|
45
|
+
Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
|
|
46
|
+
).toISOString();
|
|
47
|
+
queries.renewAuthSession(token, expiresAt);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function deleteSession(token: string): void {
|
|
51
|
+
queries.deleteAuthSession(token);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function cleanupExpiredSessions(): void {
|
|
55
|
+
queries.deleteExpiredAuthSessions();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const COOKIE_NAME = "claude_deck_session";
|
|
59
|
+
|
|
60
|
+
export function buildSessionCookie(token: string): string {
|
|
61
|
+
const maxAge = SESSION_DURATION_DAYS * 24 * 60 * 60;
|
|
62
|
+
return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildClearCookie(): string {
|
|
66
|
+
return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCookies(
|
|
70
|
+
cookieHeader: string | undefined
|
|
71
|
+
): Record<string, string> {
|
|
72
|
+
if (!cookieHeader) return {};
|
|
73
|
+
return Object.fromEntries(
|
|
74
|
+
cookieHeader.split(";").map((c) => {
|
|
75
|
+
const [key, ...rest] = c.trim().split("=");
|
|
76
|
+
return [key, rest.join("=")];
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasUsers(): boolean {
|
|
82
|
+
return queries.getUserCount() > 0;
|
|
83
|
+
}
|
package/lib/auth/totp.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TOTP, Secret } from "otpauth";
|
|
2
|
+
|
|
3
|
+
const ISSUER = "ClaudeDeck";
|
|
4
|
+
|
|
5
|
+
export function generateTotpSecret(username: string): {
|
|
6
|
+
secret: string;
|
|
7
|
+
uri: string;
|
|
8
|
+
} {
|
|
9
|
+
const secret = new Secret({ size: 20 });
|
|
10
|
+
const totp = new TOTP({
|
|
11
|
+
issuer: ISSUER,
|
|
12
|
+
label: username,
|
|
13
|
+
algorithm: "SHA1",
|
|
14
|
+
digits: 6,
|
|
15
|
+
period: 30,
|
|
16
|
+
secret,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
secret: secret.base32,
|
|
21
|
+
uri: totp.toString(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function verifyTotpCode(secret: string, code: string): boolean {
|
|
26
|
+
const totp = new TOTP({
|
|
27
|
+
issuer: ISSUER,
|
|
28
|
+
algorithm: "SHA1",
|
|
29
|
+
digits: 6,
|
|
30
|
+
period: 30,
|
|
31
|
+
secret: Secret.fromBase32(secret),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const delta = totp.validate({ token: code, window: 1 });
|
|
35
|
+
return delta !== null;
|
|
36
|
+
}
|
package/lib/db/queries.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
ProjectDevServer,
|
|
7
7
|
ProjectRepository,
|
|
8
8
|
DevServer,
|
|
9
|
+
User,
|
|
10
|
+
AuthSession,
|
|
9
11
|
} from "./types";
|
|
10
12
|
|
|
11
13
|
function query<T>(sql: string, params: unknown[] = []): T[] {
|
|
@@ -457,4 +459,66 @@ export const queries = {
|
|
|
457
459
|
itemType,
|
|
458
460
|
itemId,
|
|
459
461
|
]),
|
|
462
|
+
|
|
463
|
+
getUserCount(): number {
|
|
464
|
+
return (
|
|
465
|
+
queryOne<{ count: number }>("SELECT COUNT(*) as count FROM users") ?? {
|
|
466
|
+
count: 0,
|
|
467
|
+
}
|
|
468
|
+
).count;
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
getUserByUsername(username: string): User | null {
|
|
472
|
+
return queryOne<User>("SELECT * FROM users WHERE username = ?", [username]);
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
getUserById(id: string): User | null {
|
|
476
|
+
return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
createUser(
|
|
480
|
+
id: string,
|
|
481
|
+
username: string,
|
|
482
|
+
passwordHash: string,
|
|
483
|
+
totpSecret: string | null
|
|
484
|
+
): void {
|
|
485
|
+
execute(
|
|
486
|
+
"INSERT INTO users (id, username, password_hash, totp_secret) VALUES (?, ?, ?, ?)",
|
|
487
|
+
[id, username, passwordHash, totpSecret]
|
|
488
|
+
);
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
getAuthSessionByToken(token: string): AuthSession | null {
|
|
492
|
+
return queryOne<AuthSession>(
|
|
493
|
+
"SELECT * FROM auth_sessions WHERE token = ?",
|
|
494
|
+
[token]
|
|
495
|
+
);
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
createAuthSession(
|
|
499
|
+
id: string,
|
|
500
|
+
token: string,
|
|
501
|
+
userId: string,
|
|
502
|
+
expiresAt: string
|
|
503
|
+
): void {
|
|
504
|
+
execute(
|
|
505
|
+
"INSERT INTO auth_sessions (id, token, user_id, expires_at) VALUES (?, ?, ?, ?)",
|
|
506
|
+
[id, token, userId, expiresAt]
|
|
507
|
+
);
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
renewAuthSession(token: string, expiresAt: string): void {
|
|
511
|
+
execute("UPDATE auth_sessions SET expires_at = ? WHERE token = ?", [
|
|
512
|
+
expiresAt,
|
|
513
|
+
token,
|
|
514
|
+
]);
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
deleteAuthSession(token: string): void {
|
|
518
|
+
execute("DELETE FROM auth_sessions WHERE token = ?", [token]);
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
deleteExpiredAuthSessions(): void {
|
|
522
|
+
execute("DELETE FROM auth_sessions WHERE expires_at < datetime('now')");
|
|
523
|
+
},
|
|
460
524
|
};
|
package/lib/db/schema.ts
CHANGED
|
@@ -110,5 +110,24 @@ export function createSchema(db: Database.Database): void {
|
|
|
110
110
|
|
|
111
111
|
INSERT OR IGNORE INTO projects (id, name, working_directory, is_uncategorized, sort_order)
|
|
112
112
|
VALUES ('uncategorized', 'Uncategorized', '~', 1, 999999);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
username TEXT NOT NULL UNIQUE,
|
|
117
|
+
password_hash TEXT NOT NULL,
|
|
118
|
+
totp_secret TEXT,
|
|
119
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
token TEXT NOT NULL UNIQUE,
|
|
125
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
126
|
+
expires_at TEXT NOT NULL,
|
|
127
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
|
|
113
132
|
`);
|
|
114
133
|
}
|
package/lib/db/types.ts
CHANGED
|
@@ -90,3 +90,19 @@ export interface DevServer {
|
|
|
90
90
|
created_at: string;
|
|
91
91
|
updated_at: string;
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
export interface User {
|
|
95
|
+
id: string;
|
|
96
|
+
username: string;
|
|
97
|
+
password_hash: string;
|
|
98
|
+
totp_secret: string | null;
|
|
99
|
+
created_at: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface AuthSession {
|
|
103
|
+
id: string;
|
|
104
|
+
token: string;
|
|
105
|
+
user_id: string;
|
|
106
|
+
expires_at: string;
|
|
107
|
+
created_at: string;
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atercates/claude-deck",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-deck": "./scripts/claude-deck"
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"@xterm/addon-search": "^0.16.0",
|
|
79
79
|
"@xterm/addon-web-links": "^0.12.0",
|
|
80
80
|
"@xterm/xterm": "^6.0.0",
|
|
81
|
+
"bcryptjs": "^3.0.3",
|
|
81
82
|
"better-sqlite3": "^12.8.0",
|
|
82
83
|
"chokidar": "^5.0.0",
|
|
83
84
|
"class-variance-authority": "^0.7.1",
|
|
@@ -88,6 +89,8 @@
|
|
|
88
89
|
"next": "^16.2.3",
|
|
89
90
|
"next-themes": "^0.4.6",
|
|
90
91
|
"node-pty": "1.2.0-beta.12",
|
|
92
|
+
"otpauth": "^9.5.0",
|
|
93
|
+
"qrcode": "^1.5.4",
|
|
91
94
|
"react": "^19.2.5",
|
|
92
95
|
"react-dom": "^19.2.5",
|
|
93
96
|
"react-markdown": "^10.1.0",
|
|
@@ -107,6 +110,7 @@
|
|
|
107
110
|
"@tauri-apps/cli": "^2.10.1",
|
|
108
111
|
"@types/better-sqlite3": "^7.6.13",
|
|
109
112
|
"@types/node": "^25.6.0",
|
|
113
|
+
"@types/qrcode": "^1.5.6",
|
|
110
114
|
"@types/react": "^19.2.14",
|
|
111
115
|
"@types/react-dom": "^19.2.3",
|
|
112
116
|
"@types/ws": "^8.18.1",
|
package/server.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
5
5
|
import * as pty from "node-pty";
|
|
6
6
|
import { initDb } from "./lib/db";
|
|
7
7
|
import { startWatcher, addUpdateClient } from "./lib/claude/watcher";
|
|
8
|
+
import {
|
|
9
|
+
validateSession,
|
|
10
|
+
parseCookies,
|
|
11
|
+
COOKIE_NAME,
|
|
12
|
+
hasUsers,
|
|
13
|
+
} from "./lib/auth";
|
|
8
14
|
|
|
9
15
|
const dev = process.env.NODE_ENV !== "production";
|
|
10
16
|
const hostname = "0.0.0.0";
|
|
@@ -35,6 +41,19 @@ app.prepare().then(async () => {
|
|
|
35
41
|
server.on("upgrade", (request, socket, head) => {
|
|
36
42
|
const { pathname } = parse(request.url || "");
|
|
37
43
|
|
|
44
|
+
// Validate auth for WebSocket connections
|
|
45
|
+
if (hasUsers()) {
|
|
46
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
47
|
+
const token = cookies[COOKIE_NAME];
|
|
48
|
+
const user = token ? validateSession(token) : null;
|
|
49
|
+
|
|
50
|
+
if (!user) {
|
|
51
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
52
|
+
socket.destroy();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
if (pathname === "/ws/terminal") {
|
|
39
58
|
terminalWss.handleUpgrade(request, socket, head, (ws) => {
|
|
40
59
|
terminalWss.emit("connection", ws, request);
|