akiyama.mizuki.guru/apps/backend/src/routes/auth.ts

381 lines
12 KiB
TypeScript

import { Elysia, t } from "elysia";
import config from "@/../config.toml";
import { jwt } from '@elysiajs/jwt';
import { AuditLog } from "@/models/audit";
import { USER_ROLES, User } from "@/models/user";
import { createAuditLog } from "@/lib/audit";
import { ulid } from "ulid";
const hardcodedOwners = new Set<string>(
Array.isArray(config.auth.hardcoded_owner)
? (config.auth.hardcoded_owner as Array<string | number>).map((id: string | number) => String(id))
: [],
);
export default new Elysia({ prefix: "/auth" })
.use(
jwt({
name: 'jwt',
secret: config.auth.jwt_secret,
})
)
.get("/me", async ({ jwt, cookie: { mizuki }, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return status(401, "Unauthorized");
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return status(401, "Unauthorized");
}
const user = await User.findOne({ userId: payload.id });
if (!user) {
return status(404, "User not found");
}
return {
id: user.userId,
discordId: user.discordId,
username: user.username,
avatar: user.avatar,
role: user.role,
};
})
.get("/discord/login", ({ redirect }) => redirect(`https://discord.com/oauth2/authorize?client_id=${config.auth.discord_client_id}&response_type=code&redirect_uri=${encodeURIComponent(config.auth.discord_redirect_uri)}&scope=guilds+identify`))
.get("/roles", () => USER_ROLES)
.post("/logout", ({ cookie: { mizuki } }) => {
mizuki.set({
value: "",
httpOnly: true,
maxAge: 0,
path: "/",
});
return { ok: true };
})
.get("/discord/callback", async ({ jwt, query, status, cookie: { mizuki }, redirect }) => {
const code = query.code;
if (!code) {
return status(400, "Missing code");
}
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: config.auth.discord_client_id,
client_secret: config.auth.discord_client_secret,
code,
grant_type: "authorization_code",
redirect_uri: config.auth.discord_redirect_uri,
}),
});
if (!tokenResponse.ok) {
console.error("Failed to exchange code for token", await tokenResponse.text());
return status(500, "Failed to exchange code for token");
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!userResponse.ok) {
console.error("Failed to fetch user info", await userResponse.text());
return status(500, "Failed to fetch user info");
}
const userData = await userResponse.json();
const isHardcodedOwner = hardcodedOwners.has(String(userData.id));
const updateSet: { username: string; avatar: string; role?: "admin" } = {
username: userData.username,
avatar: userData.avatar,
};
if (isHardcodedOwner) {
updateSet.role = "admin";
}
const user = await User.findOneAndUpdate(
{ discordId: userData.id },
{
$set: updateSet,
$setOnInsert: {
userId: ulid(),
discordId: userData.id,
},
},
{ upsert: true, new: true },
);
const ensuredUser = user && !user.userId
? await User.findOneAndUpdate(
{ discordId: userData.id },
{ $set: { userId: ulid() } },
{ new: true },
)
: user;
const token = await jwt.sign({
id: ensuredUser?.userId,
discordId: userData.id,
username: userData.username,
avatar: userData.avatar,
role: ensuredUser?.role ?? "reader",
});
mizuki.set({
value: token,
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
return redirect("/");
}, {
query: t.Object({
code: t.String(),
})
})
.post("/role", async ({ jwt, body, cookie: { mizuki }, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return status(401, "Unauthorized");
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return status(401, "Unauthorized");
}
const requester = await User.findOne({ userId: payload.id });
if (!requester) {
return status(401, "Unauthorized");
}
if (requester.role !== "admin") {
return status(403, "Forbidden");
}
const target = await User.findOne({ userId: body.userId });
if (!target) {
return status(404, "User not found");
}
if (hardcodedOwners.has(target.discordId) && body.role !== "admin") {
return status(400, "Hardcoded owner role must remain admin");
}
const updated = await User.findOneAndUpdate(
{ userId: body.userId },
{ $set: { role: body.role } },
{ new: true },
);
if (!updated) {
return status(404, "User not found");
}
if (requester.userId === updated.userId) {
const nextToken = await jwt.sign({
id: updated.userId,
discordId: updated.discordId,
username: updated.username,
avatar: updated.avatar,
role: updated.role,
});
mizuki.set({
value: nextToken,
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
}
await createAuditLog({
actor: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "auth.role.update",
targetType: "user",
targetId: updated.userId,
summary: `${requester.username} changed role of ${updated.username} to ${updated.role}`,
detail: {
role: updated.role,
username: updated.username,
},
});
return {
id: updated.userId,
discordId: updated.discordId,
username: updated.username,
avatar: updated.avatar,
role: updated.role,
};
}, {
body: t.Object({
userId: t.String(),
role: t.Union([
t.Literal("admin"),
t.Literal("writer"),
t.Literal("reader"),
]),
}),
})
.get("/users", async ({ jwt, cookie: { mizuki }, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return status(401, "Unauthorized");
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return status(401, "Unauthorized");
}
const requester = await User.findOne({ userId: payload.id });
if (!requester) {
return status(401, "Unauthorized");
}
if (requester.role !== "admin") {
return status(403, "Forbidden");
}
const users = await User.find().sort({ createdAt: -1 });
return users.map((user) => ({
id: user.userId,
discordId: user.discordId,
username: user.username,
avatar: user.avatar,
role: user.role,
createdAt: user.createdAt,
}));
})
.post("/user", async ({ jwt, body, cookie: { mizuki }, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return status(401, "Unauthorized");
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return status(401, "Unauthorized");
}
const requester = await User.findOne({ userId: payload.id });
if (!requester) {
return status(401, "Unauthorized");
}
if (requester.role !== "admin") {
return status(403, "Forbidden");
}
const role = hardcodedOwners.has(body.discordId) ? "admin" : body.role;
try {
const created = await User.create({
userId: ulid(),
discordId: body.discordId,
username: body.username,
avatar: body.avatar,
role,
});
await createAuditLog({
actor: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "auth.user.create",
targetType: "user",
targetId: created.userId,
summary: `${requester.username} created user ${created.username}`,
detail: {
username: created.username,
discordId: created.discordId,
role: created.role,
},
});
return {
id: created.userId,
discordId: created.discordId,
username: created.username,
avatar: created.avatar,
role: created.role,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create user";
return status(400, message);
}
}, {
body: t.Object({
discordId: t.String({ minLength: 1 }),
username: t.String({ minLength: 1 }),
avatar: t.Optional(t.String()),
role: t.Union([
t.Literal("admin"),
t.Literal("writer"),
t.Literal("reader"),
]),
}),
})
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return status(401, "Unauthorized");
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return status(401, "Unauthorized");
}
const requester = await User.findOne({ userId: payload.id });
if (!requester) {
return status(401, "Unauthorized");
}
if (requester.role !== "admin") {
return status(403, "Forbidden");
}
const page = query.page;
const size = query.size;
const logs = await AuditLog.find()
.sort({ createdAt: -1 })
.skip((page - 1) * size)
.limit(size);
return logs;
}, {
query: t.Object({
page: t.Number({ default: 1, minimum: 1 }),
size: t.Number({ default: 30, minimum: 1, maximum: 200 }),
}),
})