mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add edit user/settings route + check for permissions (#873)
This commit is contained in:
commit
e9db7b6285
@ -93,3 +93,10 @@ RABBITMQ_HOST=rabbitmq
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_DEFAULT_USER=kyoo
|
||||
RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha
|
||||
|
||||
|
||||
# v5 stuff, does absolutely nothing on master (aka: you can delete this)
|
||||
EXTRA_CLAIMS='{"permissions": [], "verified": false}'
|
||||
FIRST_USER_CLAIMS='{"permissions": ["user.read", "users.write", "users.delete"], "verified": true}'
|
||||
GUEST_CLAIMS='{"permissions": []}'
|
||||
PROTECTED_CLAIMS="permissions,verified"
|
||||
|
1
.github/workflows/api-test.yml
vendored
1
.github/workflows/api-test.yml
vendored
@ -36,5 +36,4 @@ jobs:
|
||||
working-directory: ./api
|
||||
run: bun test
|
||||
env:
|
||||
JWT_SECRET: "TODO"
|
||||
POSTGRES_SERVER: localhost
|
||||
|
5
.github/workflows/auth-hurl.yml
vendored
5
.github/workflows/auth-hurl.yml
vendored
@ -57,8 +57,3 @@ jobs:
|
||||
working-directory: ./auth
|
||||
run: cat logs
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: results
|
||||
path: auth/out
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Elysia, { getSchemaValidator, t } from "elysia";
|
||||
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
||||
import Elysia, { t } from "elysia";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { KError } from "./models/error";
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET
|
||||
? new TextEncoder().encode(process.env.JWT_SECRET)
|
||||
@ -14,35 +14,47 @@ const jwks = createRemoteJWKSet(
|
||||
|
||||
const Jwt = t.Object({
|
||||
sub: t.String({ description: "User id" }),
|
||||
username: t.String(),
|
||||
sid: t.String({ description: "Session id" }),
|
||||
username: t.String(),
|
||||
permissions: t.Array(t.String()),
|
||||
});
|
||||
const validator = getSchemaValidator(Jwt);
|
||||
const validator = TypeCompiler.Compile(Jwt);
|
||||
|
||||
export const auth = new Elysia({ name: "auth" })
|
||||
.guard({
|
||||
// Those are not applied for now. See https://github.com/elysiajs/elysia/issues/1139
|
||||
detail: {
|
||||
security: [{ bearer: ["read"] }, { api: ["read"] }],
|
||||
},
|
||||
response: {
|
||||
401: { ...KError, description: "" },
|
||||
403: { ...KError, description: "" },
|
||||
},
|
||||
headers: t.Object({
|
||||
authorization: t.TemplateLiteral("Bearer ${string}"),
|
||||
}),
|
||||
})
|
||||
.macro({
|
||||
permissions(perms: string[]) {
|
||||
return {
|
||||
resolve: async ({ headers: { authorization }, error }) => {
|
||||
console.log(process.env.JWT_ISSUER);
|
||||
const bearer = authorization?.slice(7);
|
||||
if (!bearer) return { jwt: false };
|
||||
if (!bearer) {
|
||||
return error(500, {
|
||||
status: 500,
|
||||
message: "No jwt, auth server configuration error.",
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error ts can't understand that there's two overload idk why
|
||||
const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, {
|
||||
issuer: process.env.JWT_ISSUER,
|
||||
});
|
||||
// TODO: use perms
|
||||
return { jwt: validator.Decode<typeof Jwt>(payload) };
|
||||
const jwt = validator.Decode(payload);
|
||||
|
||||
for (const perm of perms) {
|
||||
if (!jwt.permissions.includes(perm)) {
|
||||
return error(403, {
|
||||
status: 403,
|
||||
message: `Missing permission: '${perm}'.`,
|
||||
details: { current: jwt.permissions, required: perms },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { jwt };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "./auth";
|
||||
import { entriesH } from "./controllers/entries";
|
||||
import { imagesH } from "./controllers/images";
|
||||
import { seasonsH } from "./controllers/seasons";
|
||||
@ -53,14 +54,43 @@ export const base = new Elysia({ name: "base" })
|
||||
export const prefix = process.env.KYOO_PREFIX ?? "";
|
||||
export const app = new Elysia({ prefix })
|
||||
.use(base)
|
||||
.use(showsH)
|
||||
.use(movies)
|
||||
.use(series)
|
||||
.use(collections)
|
||||
.use(entriesH)
|
||||
.use(seasonsH)
|
||||
.use(studiosH)
|
||||
.use(staffH)
|
||||
.use(videosH)
|
||||
.use(imagesH)
|
||||
.use(seed);
|
||||
.use(auth)
|
||||
.guard(
|
||||
{
|
||||
// Those are not applied for now. See https://github.com/elysiajs/elysia/issues/1139
|
||||
detail: {
|
||||
security: [{ bearer: ["core.read"] }, { api: ["core.read"] }],
|
||||
},
|
||||
// See https://github.com/elysiajs/elysia/issues/1158
|
||||
// response: {
|
||||
// 401: { ...KError, description: "" },
|
||||
// 403: { ...KError, description: "" },
|
||||
// },
|
||||
permissions: ["core.read"],
|
||||
},
|
||||
(app) =>
|
||||
app
|
||||
.use(showsH)
|
||||
.use(movies)
|
||||
.use(series)
|
||||
.use(collections)
|
||||
.use(entriesH)
|
||||
.use(seasonsH)
|
||||
.use(studiosH)
|
||||
.use(staffH)
|
||||
.use(imagesH),
|
||||
)
|
||||
.guard(
|
||||
{
|
||||
detail: {
|
||||
security: [{ bearer: ["core.write"] }, { api: ["core.write"] }],
|
||||
},
|
||||
// See https://github.com/elysiajs/elysia/issues/1158
|
||||
// response: {
|
||||
// 401: { ...KError, description: "" },
|
||||
// 403: { ...KError, description: "" },
|
||||
// },
|
||||
permissions: ["core.write"],
|
||||
},
|
||||
(app) => app.use(videosH).use(seed),
|
||||
);
|
||||
|
@ -274,9 +274,12 @@ export const entriesH = new Elysia({ tags: ["series"] })
|
||||
}),
|
||||
after: t.Optional(t.String({ description: desc.after })),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Entry),
|
||||
404: {
|
||||
|
@ -196,9 +196,12 @@ export const imagesH = new Elysia({ tags: ["images"] })
|
||||
},
|
||||
)
|
||||
.guard({
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage(),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage(),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
})
|
||||
.get(
|
||||
"/studios/:id/logo",
|
||||
|
@ -128,9 +128,12 @@ export const seasonsH = new Elysia({ tags: ["series"] })
|
||||
}),
|
||||
after: t.Optional(t.String({ description: desc.after })),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Season),
|
||||
404: {
|
||||
|
@ -87,9 +87,12 @@ export const collections = new Elysia({
|
||||
description: "Include related resources in the response.",
|
||||
}),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage(),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage(),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: { ...FullCollection, description: "Found" },
|
||||
404: {
|
||||
@ -170,9 +173,12 @@ export const collections = new Elysia({
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Collection),
|
||||
422: KError,
|
||||
@ -203,9 +209,12 @@ export const collections = new Elysia({
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
})
|
||||
.get(
|
||||
"/:id/movies",
|
||||
|
@ -77,9 +77,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||
description: "Include related resources in the response.",
|
||||
}),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage(),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage(),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: { ...FullMovie, description: "Found" },
|
||||
404: {
|
||||
@ -160,9 +163,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Movie),
|
||||
422: KError,
|
||||
|
@ -77,9 +77,12 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||
description: "Include related resources in the response.",
|
||||
}),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage(),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage(),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: { ...FullSerie, description: "Found" },
|
||||
404: {
|
||||
@ -160,9 +163,12 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Serie),
|
||||
422: KError,
|
||||
|
@ -105,9 +105,12 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Show),
|
||||
422: KError,
|
||||
|
@ -175,9 +175,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
description: "Include related resources in the response.",
|
||||
}),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage(),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage(),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: "studio",
|
||||
404: {
|
||||
@ -249,9 +252,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
}),
|
||||
after: t.Optional(t.String({ description: desc.after })),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
response: {
|
||||
200: Page(Studio),
|
||||
422: KError,
|
||||
@ -282,9 +288,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: t.Object({
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
}),
|
||||
headers: t.Object(
|
||||
{
|
||||
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
})
|
||||
.get(
|
||||
"/:id/shows",
|
||||
|
17
api/tests/helpers/jwt.ts
Normal file
17
api/tests/helpers/jwt.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
export async function getJwtHeaders() {
|
||||
const jwt = await new SignJWT({
|
||||
sub: "39158be0-3f59-4c45-b00d-d25b3bc2b884",
|
||||
sid: "04ac7ecc-255b-481d-b0c8-537c1578e3d5",
|
||||
username: "test-username",
|
||||
permissions: ["core.read", "core.write"],
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setIssuer(process.env.JWT_ISSUER!)
|
||||
.setExpirationTime("2h")
|
||||
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
|
||||
|
||||
return { Authorization: `Bearer ${jwt}` };
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { buildUrl } from "tests/utils";
|
||||
import { app } from "~/base";
|
||||
import type { SeedMovie } from "~/models/movie";
|
||||
import { getJwtHeaders } from "./jwt";
|
||||
|
||||
export const getMovie = async (
|
||||
id: string,
|
||||
@ -15,8 +16,9 @@ export const getMovie = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -41,8 +43,9 @@ export const getMovies = async ({
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -56,6 +59,7 @@ export const createMovie = async (movie: SeedMovie) => {
|
||||
body: JSON.stringify(movie),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await getJwtHeaders()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { buildUrl } from "tests/utils";
|
||||
import { app } from "~/base";
|
||||
import type { SeedSerie } from "~/models/serie";
|
||||
import { getJwtHeaders } from "./jwt";
|
||||
|
||||
export const createSerie = async (serie: SeedSerie) => {
|
||||
const resp = await app.handle(
|
||||
@ -9,6 +10,7 @@ export const createSerie = async (serie: SeedSerie) => {
|
||||
body: JSON.stringify(serie),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await getJwtHeaders()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@ -29,8 +31,9 @@ export const getSerie = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -58,8 +61,9 @@ export const getSeasons = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -87,8 +91,9 @@ export const getEntries = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -108,6 +113,7 @@ export const getExtras = async (
|
||||
const resp = await app.handle(
|
||||
new Request(buildUrl(`series/${serie}/extras`, opts), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -124,6 +130,7 @@ export const getUnknowns = async (opts: {
|
||||
const resp = await app.handle(
|
||||
new Request(buildUrl("unknowns", opts), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -147,8 +154,9 @@ export const getNews = async ({
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { buildUrl } from "tests/utils";
|
||||
import { app } from "~/base";
|
||||
import { getJwtHeaders } from "./jwt";
|
||||
|
||||
export const getStaff = async (id: string, query: {}) => {
|
||||
const resp = await app.handle(
|
||||
new Request(buildUrl(`staff/${id}`, query), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -32,8 +34,9 @@ export const getStaffRoles = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -52,6 +55,7 @@ export const getSerieStaff = async (
|
||||
const resp = await app.handle(
|
||||
new Request(buildUrl(`series/${serie}/staff`, opts), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -70,6 +74,7 @@ export const getMovieStaff = async (
|
||||
const resp = await app.handle(
|
||||
new Request(buildUrl(`movies/${movie}/staff`, opts), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { buildUrl } from "tests/utils";
|
||||
import { app } from "~/base";
|
||||
import { getJwtHeaders } from "./jwt";
|
||||
|
||||
export const getStudio = async (
|
||||
id: string,
|
||||
@ -11,8 +12,9 @@ export const getStudio = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
@ -40,8 +42,9 @@ export const getShowsByStudio = async (
|
||||
headers: langs
|
||||
? {
|
||||
"Accept-Language": langs,
|
||||
...(await getJwtHeaders()),
|
||||
}
|
||||
: {},
|
||||
: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
const body = await resp.json();
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { buildUrl } from "tests/utils";
|
||||
import { app } from "~/base";
|
||||
import type { SeedVideo } from "~/models/video";
|
||||
import { getJwtHeaders } from "./jwt";
|
||||
|
||||
export const createVideo = async (video: SeedVideo | SeedVideo[]) => {
|
||||
const resp = await app.handle(
|
||||
@ -9,6 +10,7 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => {
|
||||
body: JSON.stringify(Array.isArray(video) ? video : [video]),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await getJwtHeaders()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getJwtHeaders } from "tests/helpers/jwt";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { db } from "~/db";
|
||||
import { shows } from "~/db/schema";
|
||||
@ -10,8 +11,8 @@ import { app, createMovie, getMovies } from "../helpers";
|
||||
beforeAll(async () => {
|
||||
await db.delete(shows);
|
||||
for (const movie of [bubble, dune1984, dune]) {
|
||||
const [ret, _] = await createMovie(movie);
|
||||
expect(ret.status).toBe(201);
|
||||
const [ret, body] = await createMovie(movie);
|
||||
expectStatus(ret, body).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
@ -73,7 +74,9 @@ describe("with a null value", () => {
|
||||
),
|
||||
});
|
||||
|
||||
resp = await app.handle(new Request(next));
|
||||
resp = await app.handle(
|
||||
new Request(next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
body = await resp.json();
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
@ -120,7 +123,9 @@ describe("with a null value", () => {
|
||||
),
|
||||
});
|
||||
|
||||
resp = await app.handle(new Request(next));
|
||||
resp = await app.handle(
|
||||
new Request(next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
body = await resp.json();
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getJwtHeaders } from "tests/helpers/jwt";
|
||||
import { expectStatus } from "tests/utils";
|
||||
import { db } from "~/db";
|
||||
import { shows } from "~/db/schema";
|
||||
@ -71,7 +72,9 @@ describe("Get all movies", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(200);
|
||||
|
||||
resp = await app.handle(new Request(body.next));
|
||||
resp = await app.handle(
|
||||
new Request(body.next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
body = await resp.json();
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
@ -104,7 +107,9 @@ describe("Get all movies", () => {
|
||||
),
|
||||
});
|
||||
|
||||
resp = await app.handle(new Request(next));
|
||||
resp = await app.handle(
|
||||
new Request(next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
body = await resp.json();
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
@ -160,7 +165,9 @@ describe("Get all movies", () => {
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].id).toBe(expectedIds[0]);
|
||||
// Get Second Page
|
||||
resp = await app.handle(new Request(body.next));
|
||||
resp = await app.handle(
|
||||
new Request(body.next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
body = await resp.json();
|
||||
|
||||
expectStatus(resp, body).toBe(200);
|
||||
@ -175,7 +182,9 @@ describe("Get all movies", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(200);
|
||||
|
||||
const resp2 = await app.handle(new Request(body.next));
|
||||
const resp2 = await app.handle(
|
||||
new Request(body.next, { headers: await getJwtHeaders() }),
|
||||
);
|
||||
const body2 = await resp2.json();
|
||||
expectStatus(resp2, body).toBe(200);
|
||||
|
||||
@ -187,7 +196,9 @@ describe("Get all movies", () => {
|
||||
|
||||
it("Get /random", async () => {
|
||||
const resp = await app.handle(
|
||||
new Request("http://localhost/movies/random"),
|
||||
new Request("http://localhost/movies/random", {
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
);
|
||||
expect(resp.status).toBe(302);
|
||||
const location = resp.headers.get("location")!;
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { beforeAll } from "bun:test";
|
||||
import { migrate } from "~/db";
|
||||
|
||||
process.env.JWT_SECRET = "this is a secret";
|
||||
process.env.JWT_ISSUER = "https://kyoo.zoriya.dev";
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate();
|
||||
});
|
||||
|
@ -14,6 +14,11 @@ EXTRA_CLAIMS='{}'
|
||||
FIRST_USER_CLAIMS='{}'
|
||||
# If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`)
|
||||
GUEST_CLAIMS=""
|
||||
# Comma separated list of claims that users without the `user.write` permissions should NOT be able to edit
|
||||
# (if you don't specify this an user could make themself administrator for example)
|
||||
# PS: `permissions` is always a protected claim since keibi uses it for user.read/user.write
|
||||
PROTECTED_CLAIMS="permissions"
|
||||
|
||||
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
|
||||
PUBLIC_URL=http://localhost:8901
|
||||
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"encoding/pem"
|
||||
"maps"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@ -22,12 +23,14 @@ type Configuration struct {
|
||||
DefaultClaims jwt.MapClaims
|
||||
FirstUserClaims jwt.MapClaims
|
||||
GuestClaims jwt.MapClaims
|
||||
ProtectedClaims []string
|
||||
ExpirationDelay time.Duration
|
||||
}
|
||||
|
||||
var DefaultConfig = Configuration{
|
||||
DefaultClaims: make(jwt.MapClaims),
|
||||
FirstUserClaims: make(jwt.MapClaims),
|
||||
ProtectedClaims: []string{"permissions"},
|
||||
ExpirationDelay: 30 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
@ -64,6 +67,9 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
|
||||
}
|
||||
}
|
||||
|
||||
protected := strings.Split(os.Getenv("PROTECTED_CLAIMS"), ",")
|
||||
ret.ProtectedClaims = append(ret.ProtectedClaims, protected...)
|
||||
|
||||
rsa_pk_path := os.Getenv("RSA_PRIVATE_KEY_PATH")
|
||||
if rsa_pk_path != "" {
|
||||
privateKeyData, err := os.ReadFile(rsa_pk_path)
|
||||
|
@ -12,6 +12,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const clearOtherSessions = `-- name: ClearOtherSessions :exec
|
||||
delete from sessions as s using users as u
|
||||
where s.user_pk = u.pk
|
||||
and s.id != $1
|
||||
and u.id = $2
|
||||
`
|
||||
|
||||
type ClearOtherSessionsParams struct {
|
||||
SessionId uuid.UUID `json:"sessionId"`
|
||||
UserId uuid.UUID `json:"userId"`
|
||||
}
|
||||
|
||||
func (q *Queries) ClearOtherSessions(ctx context.Context, arg ClearOtherSessionsParams) error {
|
||||
_, err := q.db.Exec(ctx, clearOtherSessions, arg.SessionId, arg.UserId)
|
||||
return err
|
||||
}
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
insert into sessions(token, user_pk, device)
|
||||
values ($1, $2, $3)
|
||||
|
@ -265,10 +265,10 @@ const updateUser = `-- name: UpdateUser :one
|
||||
update
|
||||
users
|
||||
set
|
||||
username = $2,
|
||||
email = $3,
|
||||
password = $4,
|
||||
claims = $5
|
||||
username = coalesce($2, username),
|
||||
email = coalesce($3, email),
|
||||
password = coalesce($4, password),
|
||||
claims = coalesce($5, claims)
|
||||
where
|
||||
id = $1
|
||||
returning
|
||||
@ -277,8 +277,8 @@ returning
|
||||
|
||||
type UpdateUserParams struct {
|
||||
Id uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
Claims jwt.MapClaims `json:"claims"`
|
||||
}
|
||||
|
@ -377,6 +377,102 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": []
|
||||
}
|
||||
],
|
||||
"description": "Edit your account's info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit self",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Edited user info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditUserDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.User"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "You can't edit a protected claim",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me/password": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": []
|
||||
}
|
||||
],
|
||||
"description": "Edit your password",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit password",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Invalidate other sessions",
|
||||
"name": "invalidate",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "New password",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditPasswordDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
@ -469,10 +565,101 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": [
|
||||
"users.write"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Edit an account info or permissions",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "User id of the user to edit",
|
||||
"name": "id",
|
||||
"in": "path"
|
||||
},
|
||||
{
|
||||
"description": "Edited user info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditUserDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.User"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "You don't have permissions to edit another account",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"main.EditPasswordDto": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "password1234"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.EditUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claims": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"preferOriginal": " true"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "kyoo@zoriya.dev"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"example": "zoriya"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.JwkSet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -371,6 +371,102 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": []
|
||||
}
|
||||
],
|
||||
"description": "Edit your account's info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit self",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Edited user info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditUserDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.User"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "You can't edit a protected claim",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me/password": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": []
|
||||
}
|
||||
],
|
||||
"description": "Edit your password",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit password",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Invalidate other sessions",
|
||||
"name": "invalidate",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "New password",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditPasswordDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
@ -463,10 +559,101 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"Jwt": [
|
||||
"users.write"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Edit an account info or permissions",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Edit user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "User id of the user to edit",
|
||||
"name": "id",
|
||||
"in": "path"
|
||||
},
|
||||
{
|
||||
"description": "Edited user info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.EditUserDto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.User"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "You don't have permissions to edit another account",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.KError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"main.EditPasswordDto": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "password1234"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.EditUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claims": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"preferOriginal": " true"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "kyoo@zoriya.dev"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"example": "zoriya"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.JwkSet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -53,7 +53,7 @@ type Validator struct {
|
||||
|
||||
func (v *Validator) Validate(i any) error {
|
||||
if err := v.validator.Struct(i); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -215,6 +215,9 @@ func main() {
|
||||
r.GET("/users/me", h.GetMe)
|
||||
r.DELETE("/users/:id", h.DeleteUser)
|
||||
r.DELETE("/users/me", h.DeleteSelf)
|
||||
r.PATCH("/users/:id", h.EditUser)
|
||||
r.PATCH("/users/me", h.EditSelf)
|
||||
r.PATCH("/users/me/password", h.ChangePassword)
|
||||
g.POST("/users", h.Register)
|
||||
|
||||
g.POST("/sessions", h.Login)
|
||||
|
@ -125,7 +125,7 @@ func (h *Handler) createSession(c echo.Context, user *User) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(201, session)
|
||||
return c.JSON(201, MapSessionToken(&session))
|
||||
}
|
||||
|
||||
// @Summary Logout
|
||||
|
@ -43,3 +43,8 @@ where s.user_pk = u.pk
|
||||
returning
|
||||
s.*;
|
||||
|
||||
-- name: ClearOtherSessions :exec
|
||||
delete from sessions as s using users as u
|
||||
where s.user_pk = u.pk
|
||||
and s.id != @session_id
|
||||
and u.id = @user_id;
|
||||
|
@ -67,10 +67,10 @@ returning
|
||||
update
|
||||
users
|
||||
set
|
||||
username = $2,
|
||||
email = $3,
|
||||
password = $4,
|
||||
claims = $5
|
||||
username = coalesce(sqlc.narg(username), username),
|
||||
email = coalesce(sqlc.narg(email), email),
|
||||
password = coalesce(sqlc.narg(password), password),
|
||||
claims = coalesce(sqlc.narg(claims), claims)
|
||||
where
|
||||
id = $1
|
||||
returning
|
||||
|
40
auth/tests/change-password.hurl
Normal file
40
auth/tests/change-password.hurl
Normal file
@ -0,0 +1,40 @@
|
||||
POST {{host}}/users
|
||||
{
|
||||
"username": "edit-password",
|
||||
"password": "password-login-user",
|
||||
"email": "invalid-password-user@zoriya.dev"
|
||||
}
|
||||
HTTP 201
|
||||
[Captures]
|
||||
first_token: jsonpath "$.token"
|
||||
|
||||
POST {{host}}/sessions
|
||||
{
|
||||
"login": "edit-password",
|
||||
"password": "password-login-user"
|
||||
}
|
||||
HTTP 201
|
||||
[Captures]
|
||||
token: jsonpath "$.token"
|
||||
|
||||
GET {{host}}/jwt
|
||||
Authorization: Bearer {{token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.token"
|
||||
|
||||
PATCH {{host}}/users/me/password
|
||||
Authorization: Bearer {{jwt}}
|
||||
{
|
||||
"password": "new-password"
|
||||
}
|
||||
HTTP 204
|
||||
|
||||
# Invalid password login
|
||||
POST {{host}}/jwt
|
||||
Authorization: Bearer {{first_token}}
|
||||
HTTP 403
|
||||
|
||||
DELETE {{host}}/users/me
|
||||
Authorization: Bearer {{jwt}}
|
||||
HTTP 200
|
35
auth/tests/edit-settings.hurl
Normal file
35
auth/tests/edit-settings.hurl
Normal file
@ -0,0 +1,35 @@
|
||||
POST {{host}}/users
|
||||
{
|
||||
"username": "edit-settings",
|
||||
"password": "password-login-user",
|
||||
"email": "edit-settings@zoriya.dev"
|
||||
}
|
||||
HTTP 201
|
||||
[Captures]
|
||||
token: jsonpath "$.token"
|
||||
|
||||
GET {{host}}/jwt
|
||||
Authorization: Bearer {{token}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.token"
|
||||
|
||||
PATCH {{host}}/users/me
|
||||
Authorization: Bearer {{jwt}}
|
||||
{
|
||||
"claims": {
|
||||
"preferOriginal": true
|
||||
}
|
||||
}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.claims.preferOriginal" == true
|
||||
jsonpath "$.username" == "edit-settings"
|
||||
|
||||
GET {{host}}/jwt
|
||||
Authorization: Bearer {{token}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{host}}/users/me
|
||||
Authorization: Bearer {{jwt}}
|
||||
HTTP 200
|
162
auth/users.go
162
auth/users.go
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -51,6 +52,16 @@ type RegisterDto struct {
|
||||
Password string `json:"password" validate:"required" example:"password1234"`
|
||||
}
|
||||
|
||||
type EditUserDto struct {
|
||||
Username *string `json:"username,omitempty" validate:"omitnil,excludes=@" example:"zoriya"`
|
||||
Email *string `json:"email,omitempty" validate:"omitnil,email" example:"kyoo@zoriya.dev"`
|
||||
Claims jwt.MapClaims `json:"claims,omitempty" example:"preferOriginal: true"`
|
||||
}
|
||||
|
||||
type EditPasswordDto struct {
|
||||
Password string `json:"password" validate:"required" example:"password1234"`
|
||||
}
|
||||
|
||||
func MapDbUser(user *dbc.User) User {
|
||||
return User{
|
||||
Pk: user.Pk,
|
||||
@ -235,6 +246,11 @@ func (h *Handler) Register(c echo.Context) error {
|
||||
// @Failure 404 {object} KError "Invalid user id"
|
||||
// @Router /users/{id} [delete]
|
||||
func (h *Handler) DeleteUser(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"user.delete"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(400, "Invalid id given: not an uuid")
|
||||
@ -271,3 +287,149 @@ func (h *Handler) DeleteSelf(c echo.Context) error {
|
||||
}
|
||||
return c.JSON(200, MapDbUser(&ret))
|
||||
}
|
||||
|
||||
// @Summary Edit self
|
||||
// @Description Edit your account's info
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt
|
||||
// @Param user body EditUserDto false "Edited user info"
|
||||
// @Success 200 {object} User
|
||||
// @Success 403 {object} KError "You can't edit a protected claim"
|
||||
// @Success 422 {object} KError "Invalid body"
|
||||
// @Router /users/me [patch]
|
||||
func (h *Handler) EditSelf(c echo.Context) error {
|
||||
var req EditUserDto
|
||||
err := c.Bind(&req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
if err = c.Validate(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range h.config.ProtectedClaims {
|
||||
if _, contains := req.Claims[key]; contains {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("Can't edit protected claim: '%s'.", key))
|
||||
}
|
||||
}
|
||||
|
||||
uid, err := GetCurrentUserId(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
|
||||
Id: uid,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Claims: req.Claims,
|
||||
})
|
||||
if err == pgx.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Invalid token, user not found.")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(200, MapDbUser(&ret))
|
||||
}
|
||||
|
||||
// @Summary Edit user
|
||||
// @Description Edit an account info or permissions
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt[users.write]
|
||||
// @Param id path string false "User id of the user to edit" Format(uuid)
|
||||
// @Param user body EditUserDto false "Edited user info"
|
||||
// @Success 200 {object} User
|
||||
// @Success 403 {object} KError "You don't have permissions to edit another account"
|
||||
// @Success 422 {object} KError "Invalid body"
|
||||
// @Router /users/{id} [patch]
|
||||
func (h *Handler) EditUser(c echo.Context) error {
|
||||
err := CheckPermissions(c, []string{"user.write"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(400, "Invalid id given: not an uuid")
|
||||
}
|
||||
|
||||
var req EditUserDto
|
||||
err = c.Bind(&req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
if err = c.Validate(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
|
||||
Id: uid,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Claims: req.Claims,
|
||||
})
|
||||
if err == pgx.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Invalid user id, user not found")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(200, MapDbUser(&ret))
|
||||
}
|
||||
|
||||
// @Summary Edit password
|
||||
// @Description Edit your password
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Jwt
|
||||
// @Param invalidate query bool false "Invalidate other sessions" default(true)
|
||||
// @Param user body EditPasswordDto false "New password"
|
||||
// @Success 204
|
||||
// @Success 422 {object} KError "Invalid body"
|
||||
// @Router /users/me/password [patch]
|
||||
func (h *Handler) ChangePassword(c echo.Context) error {
|
||||
uid, err := GetCurrentUserId(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sid, err := GetCurrentSessionId(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req EditPasswordDto
|
||||
err = c.Bind(&req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
if err = c.Validate(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateUser(context.Background(), dbc.UpdateUserParams{
|
||||
Id: uid,
|
||||
Password: &req.Password,
|
||||
})
|
||||
if err == pgx.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Invalid token, user not found")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = h.db.ClearOtherSessions(context.Background(), dbc.ClearOtherSessionsParams{
|
||||
SessionId: sid,
|
||||
UserId: uid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
@ -28,6 +28,32 @@ func GetCurrentUserId(c echo.Context) (uuid.UUID, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func GetCurrentSessionId(c echo.Context) (uuid.UUID, error) {
|
||||
user := c.Get("user").(*jwt.Token)
|
||||
if user == nil {
|
||||
return uuid.UUID{}, echo.NewHTTPError(401, "Unauthorized")
|
||||
}
|
||||
claims, ok := user.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrieve claims")
|
||||
}
|
||||
sid, ok := claims["sid"]
|
||||
if !ok {
|
||||
return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrieve session")
|
||||
}
|
||||
|
||||
sid_str, ok := sid.(string)
|
||||
if !ok {
|
||||
return uuid.UUID{}, echo.NewHTTPError(403, "Invalid session id claim.")
|
||||
}
|
||||
|
||||
ret, err := uuid.Parse(sid_str)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, echo.NewHTTPError(403, "Invalid id")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func CheckPermissions(c echo.Context, perms []string) error {
|
||||
token, ok := c.Get("user").(*jwt.Token)
|
||||
if !ok {
|
||||
|
Loading…
x
Reference in New Issue
Block a user