Add edit user/settings route + check for permissions (#873)

This commit is contained in:
Zoe Roux 2025-04-06 14:41:01 +02:00 committed by GitHub
commit e9db7b6285
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 921 additions and 105 deletions

View File

@ -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"

View File

@ -36,5 +36,4 @@ jobs:
working-directory: ./api
run: bun test
env:
JWT_SECRET: "TODO"
POSTGRES_SERVER: localhost

View File

@ -57,8 +57,3 @@ jobs:
working-directory: ./auth
run: cat logs
- uses: actions/upload-artifact@v4
with:
name: results
path: auth/out

View File

@ -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 };
},
};
},

View File

@ -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),
);

View File

@ -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: {

View File

@ -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",

View File

@ -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: {

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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
View 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}` };
}

View File

@ -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()),
},
}),
);

View File

@ -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();

View File

@ -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();

View File

@ -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();

View File

@ -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()),
},
}),
);

View File

@ -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);

View File

@ -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")!;

View File

@ -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();
});

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"`
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -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

View 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

View 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

View File

@ -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)
}

View File

@ -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 {