wow
This commit is contained in:
parent
b12ebb725d
commit
5207f5d431
25 changed files with 2932 additions and 332 deletions
381
apps/backend/src/routes/auth.ts
Normal file
381
apps/backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
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: "/api",
|
||||
});
|
||||
|
||||
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: "/api",
|
||||
});
|
||||
|
||||
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: "/api",
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
}),
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue