diff --git a/.env.example b/.env.example index f81f9357..7ca66f24 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/.github/workflows/api-test.yml b/.github/workflows/api-test.yml index fa19c07b..46e170f0 100644 --- a/.github/workflows/api-test.yml +++ b/.github/workflows/api-test.yml @@ -36,5 +36,4 @@ jobs: working-directory: ./api run: bun test env: - JWT_SECRET: "TODO" POSTGRES_SERVER: localhost diff --git a/.github/workflows/auth-hurl.yml b/.github/workflows/auth-hurl.yml index d5f47917..6ecbed1c 100644 --- a/.github/workflows/auth-hurl.yml +++ b/.github/workflows/auth-hurl.yml @@ -57,8 +57,3 @@ jobs: working-directory: ./auth run: cat logs - - uses: actions/upload-artifact@v4 - with: - name: results - path: auth/out - diff --git a/api/src/auth.ts b/api/src/auth.ts index abae1100..ff6bd3e5 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -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(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 }; }, }; }, diff --git a/api/src/base.ts b/api/src/base.ts index 248ac1a1..d6de2a49 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -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), + ); diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index c6618283..b5546ca4 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -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: { diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index 865af6cc..170f4e2b 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -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", diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts index d4559727..1a28683b 100644 --- a/api/src/controllers/seasons.ts +++ b/api/src/controllers/seasons.ts @@ -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: { diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index cb5474e6..f2fd177a 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -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", diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 829edc7a..2781d171 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -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, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index c788efdd..cd7d3440 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -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, diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index 2b6e5f87..a61983fb 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -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, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 65ce97e9..ba8a5290 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -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", diff --git a/api/tests/helpers/jwt.ts b/api/tests/helpers/jwt.ts new file mode 100644 index 00000000..80efc31b --- /dev/null +++ b/api/tests/helpers/jwt.ts @@ -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}` }; +} diff --git a/api/tests/helpers/movies-helper.ts b/api/tests/helpers/movies-helper.ts index c7ddba29..85b09916 100644 --- a/api/tests/helpers/movies-helper.ts +++ b/api/tests/helpers/movies-helper.ts @@ -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()), }, }), ); diff --git a/api/tests/helpers/series-helper.ts b/api/tests/helpers/series-helper.ts index 776c09eb..7004278e 100644 --- a/api/tests/helpers/series-helper.ts +++ b/api/tests/helpers/series-helper.ts @@ -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(); diff --git a/api/tests/helpers/staff-helper.ts b/api/tests/helpers/staff-helper.ts index a4a4b99b..dbb2a613 100644 --- a/api/tests/helpers/staff-helper.ts +++ b/api/tests/helpers/staff-helper.ts @@ -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(); diff --git a/api/tests/helpers/studio-helper.ts b/api/tests/helpers/studio-helper.ts index bc6ccfe8..0ea8ea21 100644 --- a/api/tests/helpers/studio-helper.ts +++ b/api/tests/helpers/studio-helper.ts @@ -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(); diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index 74232b7c..380cd81d 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -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()), }, }), ); diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts index 5b633510..2cba7fb6 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -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); diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 9eb9f2af..94fef089 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -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")!; diff --git a/api/tests/setup.ts b/api/tests/setup.ts index ab07c363..e9b014f1 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -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(); }); diff --git a/auth/.env.example b/auth/.env.example index 2ce80788..1e69fdee 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -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 diff --git a/auth/config.go b/auth/config.go index ed3cc061..cc7ac656 100644 --- a/auth/config.go +++ b/auth/config.go @@ -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) diff --git a/auth/dbc/sessions.sql.go b/auth/dbc/sessions.sql.go index 02509fd9..212ab91f 100644 --- a/auth/dbc/sessions.sql.go +++ b/auth/dbc/sessions.sql.go @@ -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) diff --git a/auth/dbc/users.sql.go b/auth/dbc/users.sql.go index 22035cee..17e2e903 100644 --- a/auth/dbc/users.sql.go +++ b/auth/dbc/users.sql.go @@ -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"` } diff --git a/auth/docs/docs.go b/auth/docs/docs.go index 124597f8..2b9f7a65 100644 --- a/auth/docs/docs.go +++ b/auth/docs/docs.go @@ -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": { diff --git a/auth/docs/swagger.json b/auth/docs/swagger.json index 2215fcf0..367b80ea 100644 --- a/auth/docs/swagger.json +++ b/auth/docs/swagger.json @@ -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": { diff --git a/auth/main.go b/auth/main.go index 05d05d26..ab76c2a0 100644 --- a/auth/main.go +++ b/auth/main.go @@ -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) diff --git a/auth/sessions.go b/auth/sessions.go index 86a05a93..b274e6fe 100644 --- a/auth/sessions.go +++ b/auth/sessions.go @@ -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 diff --git a/auth/sql/queries/sessions.sql b/auth/sql/queries/sessions.sql index 82127d80..b665848f 100644 --- a/auth/sql/queries/sessions.sql +++ b/auth/sql/queries/sessions.sql @@ -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; diff --git a/auth/sql/queries/users.sql b/auth/sql/queries/users.sql index 6881c861..1f1711dd 100644 --- a/auth/sql/queries/users.sql +++ b/auth/sql/queries/users.sql @@ -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 diff --git a/auth/tests/change-password.hurl b/auth/tests/change-password.hurl new file mode 100644 index 00000000..5ff69a04 --- /dev/null +++ b/auth/tests/change-password.hurl @@ -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 diff --git a/auth/tests/edit-settings.hurl b/auth/tests/edit-settings.hurl new file mode 100644 index 00000000..d33914e3 --- /dev/null +++ b/auth/tests/edit-settings.hurl @@ -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 diff --git a/auth/users.go b/auth/users.go index 53589652..e0bfdb79 100644 --- a/auth/users.go +++ b/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) +} diff --git a/auth/utils.go b/auth/utils.go index 20b8c377..70bca2fb 100644 --- a/auth/utils.go +++ b/auth/utils.go @@ -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 {