diff --git a/.github/workflows/robot_auth.yml b/.github/workflows/auth-hurl.yml similarity index 72% rename from .github/workflows/robot_auth.yml rename to .github/workflows/auth-hurl.yml index 9a821059..d5f47917 100644 --- a/.github/workflows/robot_auth.yml +++ b/.github/workflows/auth-hurl.yml @@ -1,4 +1,4 @@ -name: RobotTests +name: HurlTests on: push: branches: @@ -9,7 +9,7 @@ on: jobs: test: - name: Robot tests Auth + name: Hurl tests Auth runs-on: ubuntu-latest services: postgres: @@ -27,13 +27,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Robot cache - uses: actions/setup-python@v5 - with: - python-version: '3.9' - cache: 'pip' - - - run: pip install -r requirements.txt + - uses: gacts/install-hurl@v1 - uses: actions/setup-go@v5 with: @@ -44,22 +38,18 @@ jobs: working-directory: ./auth run: | go mod download - go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest - go install github.com/swaggo/swag/cmd/swag@latest - name: Build working-directory: ./auth run: | - sqlc generate - swag init --parseDependency go build -o ./keibi - - name: Run robot tests + - name: Run hurl tests working-directory: ./auth run: | ./keibi > logs & wget --retry-connrefused --retry-on-http-error=502 http://localhost:4568/health - robot -d out robot + hurl --error-format long --variable host=http://localhost:4568 tests/* env: POSTGRES_SERVER: localhost diff --git a/api/.env.example b/api/.env.example index f7bf9c67..3f5257dc 100644 --- a/api/.env.example +++ b/api/.env.example @@ -3,9 +3,12 @@ KYOO_PREFIX=/api + # either an hard-coded secret to decode jwts or empty to use keibi's public secret. # this should only be used in tests JWT_SECRET= +# used to verify who's making the jwt +JWT_ISSUER=$PUBLIC_URL # keibi's server to retrieve the public jwt secret AUHT_SERVER=http://auth:4568 diff --git a/api/bun.lock b/api/bun.lock index 30156aa0..d6a113f0 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -4,7 +4,7 @@ "": { "name": "api", "dependencies": { - "@elysiajs/swagger": "^1.2.2", + "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", "drizzle-kit": "^0.30.4", "drizzle-orm": "0.39.0", @@ -27,7 +27,7 @@ "packages": { "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@elysiajs/swagger": ["@elysiajs/swagger@1.2.2", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "sha512-DG0PbX/wzQNQ6kIpFFPCvmkkWTIbNWDS7lVLv3Puy6ONklF14B4NnbDfpYjX1hdSYKeCqKBBOuenh6jKm8tbYA=="], + "@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#ef89c17", { "dependencies": { "@scalar/themes": "^0.9.81", "@scalar/types": "^0.1.3", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "zoriya-elysia-swagger-ef89c17"], "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="], @@ -121,15 +121,15 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], - "@scalar/themes": ["@scalar/themes@0.9.79", "", { "dependencies": { "@scalar/types": "0.1.1" } }, "sha512-zWiHCZAIjPGa8X9o/NORBPRMTMblLEz2+2RcfW9yIKNO/8H4Gz0rltiGGlJ6vX0o+qHwx7AdgfY+7njmWQR4ng=="], + "@scalar/themes": ["@scalar/themes@0.9.81", "", { "dependencies": { "@scalar/types": "0.1.3" } }, "sha512-asTgdqo8ZYibBBWVYy0503qPx3cvwDlYNuc/cLbrCmTav0MAEL4wNb/gz9iScMVSMwhdkSkL5g9LPdr2mQrHzw=="], - "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + "@scalar/types": ["@scalar/types@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-Fxtgjp5wHhTzXiyODYWIoTsTy3oFC70vme+0I7MNwd8i6D8qplFNnpURueqBuP4MglBM2ZhFv3hPLw4D69anDA=="], - "@sinclair/typebox": ["@sinclair/typebox@0.34.30", "", {}, "sha512-gFB3BiqjDxEoadW0zn+xyMVb7cLxPCoblVn2C/BKpI41WPYi2d6fwHAlynPNZ5O/Q4WEiujdnJzVtvG/Jc2CBQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.31", "", {}, "sha512-qQ71T9DsITbX3dVCrcBERbs11YuSMg3wZPnT472JhqhWGPdiLgyvihJXU8m+ADJtJvRdjATIiACJD22dEknBrQ=="], - "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], + "@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="], "@types/pg": ["@types/pg@8.11.11", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw=="], @@ -141,7 +141,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], + "bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="], "char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="], @@ -199,7 +199,7 @@ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - "pg": ["pg@8.14.0", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-nXbVpyoaXVmdqlKEzToFf37qzyeeh7mbiXsnoWvstSqohj88yaa/I/Rq/HEVn2QPSZEuLIJa/jSpRDyzjEx4FQ=="], + "pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="], "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], @@ -259,8 +259,6 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@scalar/themes/@scalar/types": ["@scalar/types@0.1.1", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-LlUX6AmOOGoRqOMoO835V2FezM1KiO5UlvQC3poT/s7oqD6ranqwRNFxyrPz/IxClPYR+SV1yBUSNKely4ZQhQ=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -307,8 +305,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], - "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], diff --git a/api/package.json b/api/package.json index abecb29f..34a50d3c 100644 --- a/api/package.json +++ b/api/package.json @@ -9,7 +9,7 @@ "format": "biome check --write ." }, "dependencies": { - "@elysiajs/swagger": "^1.2.2", + "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", "drizzle-kit": "^0.30.4", "drizzle-orm": "0.39.0", diff --git a/api/src/auth.ts b/api/src/auth.ts index 398fd1f9..abae1100 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -34,10 +34,13 @@ export const auth = new Elysia({ name: "auth" }) 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 }; // @ts-expect-error ts can't understand that there's two overload idk why - const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks); + const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, { + issuer: process.env.JWT_ISSUER, + }); // TODO: use perms return { jwt: validator.Decode(payload) }; }, diff --git a/api/src/index.ts b/api/src/index.ts index b029433f..0926dc30 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,4 +1,5 @@ import { swagger } from "@elysiajs/swagger"; +import Elysia from "elysia"; import { app } from "./base"; import { processImages } from "./controllers/seed/images"; import { migrate } from "./db"; @@ -9,9 +10,15 @@ await migrate(); // run image processor task in background processImages(); -app +new Elysia() .use( swagger({ + scalarConfig: { + sources: [ + { slug: "kyoo", url: "/swagger/json" }, + { slug: "keibi", url: "http://localhost:4568/auth/swagger/doc.json" }, + ], + }, documentation: { info: { title: "Kyoo", @@ -72,6 +79,7 @@ app }, }), ) + .use(app) .listen(3567); console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`); diff --git a/api/src/models/utils/descriptions.ts b/api/src/models/utils/descriptions.ts index 0e683805..8ace9d4d 100644 --- a/api/src/models/utils/descriptions.ts +++ b/api/src/models/utils/descriptions.ts @@ -8,7 +8,7 @@ export const desc = { `, after: comment` - Id of the cursor in the pagination. + Cursor for the pagination. You can ignore this and only use the prev/next field in the response. `, diff --git a/auth/.env.example b/auth/.env.example index de7c4b08..2ce80788 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -4,6 +4,16 @@ # http route prefix (will listen to $KEIBI_PREFIX/users for example) KEIBI_PREFIX="" +# path of the private key used to sign jwts. If this is empty, a new one will be generated on startup +RSA_PRIVATE_KEY_PATH="" + +# json object with the claims to add to every jwt (this is read when creating a new user) +EXTRA_CLAIMS='{}' +# json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin). +# Those claims are merged with the `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="" # 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/Dockerfile b/auth/Dockerfile index 35654b31..cc700b89 100644 --- a/auth/Dockerfile +++ b/auth/Dockerfile @@ -12,7 +12,7 @@ COPY sql ./sql RUN sqlc generate COPY . . -RUN swag init --parseDependency +RUN swag init --parseDependency --outputTypes json,go RUN CGO_ENABLED=0 GOOS=linux go build -o /keibi FROM gcr.io/distroless/base-debian11 diff --git a/auth/README.md b/auth/README.md index 4ffb3538..12391e4d 100644 --- a/auth/README.md +++ b/auth/README.md @@ -10,6 +10,7 @@ - Username/password login - OIDC (login via Google, Discord, Authentik, whatever) - Custom jwt claims (for your role/permissions handling or something else) +- Guest handling (only if using `GUEST_CLAIMS`) - Api keys support - Optionally [Federated](#federated) diff --git a/auth/config.go b/auth/config.go index 737226da..ed3cc061 100644 --- a/auth/config.go +++ b/auth/config.go @@ -1,11 +1,12 @@ package main import ( - "context" "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/json" "encoding/pem" + "maps" "os" "time" @@ -19,64 +20,77 @@ type Configuration struct { JwtPublicKey *rsa.PublicKey PublicUrl string DefaultClaims jwt.MapClaims + FirstUserClaims jwt.MapClaims + GuestClaims jwt.MapClaims ExpirationDelay time.Duration } var DefaultConfig = Configuration{ DefaultClaims: make(jwt.MapClaims), + FirstUserClaims: make(jwt.MapClaims), ExpirationDelay: 30 * 24 * time.Hour, } -const ( - JwtPrivateKey = "jwt_private_key" -) - func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { - ctx := context.Background() - confs, err := db.LoadConfig(ctx) - if err != nil { - return nil, err - } - ret := DefaultConfig - for _, conf := range confs { - switch conf.Key { - case JwtPrivateKey: - block, _ := pem.Decode([]byte(conf.Value)) - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - ret.JwtPrivateKey = key - ret.JwtPublicKey = &key.PublicKey - } - } - ret.PublicUrl = os.Getenv("PUBLIC_URL") ret.Prefix = os.Getenv("KEIBI_PREFIX") - if ret.JwtPrivateKey == nil { + claims := os.Getenv("EXTRA_CLAIMS") + if claims != "" { + err := json.Unmarshal([]byte(claims), &ret.DefaultClaims) + if err != nil { + return nil, err + } + } + claims = os.Getenv("FIRST_USER_CLAIMS") + if claims != "" { + err := json.Unmarshal([]byte(claims), &ret.FirstUserClaims) + if err != nil { + return nil, err + } + + maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims)) + } else { + ret.FirstUserClaims = ret.DefaultClaims + } + + claims = os.Getenv("GUEST_CLAIMS") + if claims != "" { + err := json.Unmarshal([]byte(claims), &ret.GuestClaims) + if err != nil { + return nil, err + } + } + + rsa_pk_path := os.Getenv("RSA_PRIVATE_KEY_PATH") + if rsa_pk_path != "" { + privateKeyData, err := os.ReadFile(rsa_pk_path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(privateKeyData) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, err + } + + ret.JwtPrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + pkcs8Key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + ret.JwtPrivateKey = pkcs8Key.(*rsa.PrivateKey) + } + } else { + var err error ret.JwtPrivateKey, err = rsa.GenerateKey(rand.Reader, 4096) if err != nil { return nil, err } ret.JwtPublicKey = &ret.JwtPrivateKey.PublicKey - - pemd := pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(ret.JwtPrivateKey), - }, - ) - - _, err := db.SaveConfig(ctx, dbc.SaveConfigParams{ - Key: JwtPrivateKey, - Value: string(pemd), - }) - if err != nil { - return nil, err - } } return &ret, nil diff --git a/auth/dbc/config.sql.go b/auth/dbc/config.sql.go deleted file mode 100644 index 5ec22ed9..00000000 --- a/auth/dbc/config.sql.go +++ /dev/null @@ -1,73 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.28.0 -// source: config.sql - -package dbc - -import ( - "context" -) - -const deleteConfig = `-- name: DeleteConfig :one -delete from config -where key = $1 -returning - key, value -` - -func (q *Queries) DeleteConfig(ctx context.Context, key string) (Config, error) { - row := q.db.QueryRow(ctx, deleteConfig, key) - var i Config - err := row.Scan(&i.Key, &i.Value) - return i, err -} - -const loadConfig = `-- name: LoadConfig :many -select - key, value -from - config -` - -func (q *Queries) LoadConfig(ctx context.Context) ([]Config, error) { - rows, err := q.db.Query(ctx, loadConfig) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Config - for rows.Next() { - var i Config - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const saveConfig = `-- name: SaveConfig :one -insert into config(key, value) - values ($1, $2) -on conflict (key) - do update set - value = excluded.value - returning - key, value -` - -type SaveConfigParams struct { - Key string `json:"key"` - Value string `json:"value"` -} - -func (q *Queries) SaveConfig(ctx context.Context, arg SaveConfigParams) (Config, error) { - row := q.db.QueryRow(ctx, saveConfig, arg.Key, arg.Value) - var i Config - err := row.Scan(&i.Key, &i.Value) - return i, err -} diff --git a/auth/dbc/models.go b/auth/dbc/models.go index 122487ae..2310f424 100644 --- a/auth/dbc/models.go +++ b/auth/dbc/models.go @@ -11,11 +11,6 @@ import ( "github.com/google/uuid" ) -type Config struct { - Key string `json:"key"` - Value string `json:"value"` -} - type OidcHandle struct { UserPk int32 `json:"userPk"` Provider string `json:"provider"` diff --git a/auth/dbc/users.sql.go b/auth/dbc/users.sql.go index 88360e27..22035cee 100644 --- a/auth/dbc/users.sql.go +++ b/auth/dbc/users.sql.go @@ -14,16 +14,25 @@ import ( const createUser = `-- name: CreateUser :one insert into users(username, email, password, claims) - values ($1, $2, $3, $4) + values ($1, $2, $3, case when not exists ( + select + pk, id, username, email, password, claims, created_date, last_seen + from + users) then + $4::jsonb + else + $5::jsonb + end) returning pk, id, username, email, password, claims, created_date, last_seen ` type CreateUserParams struct { - Username string `json:"username"` - Email string `json:"email"` - Password *string `json:"password"` - Claims jwt.MapClaims `json:"claims"` + Username string `json:"username"` + Email string `json:"email"` + Password *string `json:"password"` + FirstClaims interface{} `json:"firstClaims"` + Claims interface{} `json:"claims"` } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { @@ -31,6 +40,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e arg.Username, arg.Email, arg.Password, + arg.FirstClaims, arg.Claims, ) var i User diff --git a/auth/docs/docs.go b/auth/docs/docs.go index e3e40a2e..124597f8 100644 --- a/auth/docs/docs.go +++ b/auth/docs/docs.go @@ -22,21 +22,21 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/info": { + "/.well-known/jwks.json": { "get": { - "description": "Get info like the public key used to sign the jwts.", + "description": "Get the jwks info, used to validate jwts.", "produces": [ "application/json" ], "tags": [ "jwt" ], - "summary": "Info", + "summary": "Jwks", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.Info" + "$ref": "#/definitions/main.JwkSet" } } } @@ -62,15 +62,19 @@ const docTemplate = `{ "description": "OK", "schema": { "$ref": "#/definitions/main.Jwt" + }, + "headers": { + "Authorization": { + "type": "string", + "description": "Jwt (same value as the returned token)" + } } }, - "401": { - "description": "Missing session token", - "schema": {} - }, "403": { "description": "Invalid session token (or expired)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -91,6 +95,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", + "example": "android tv", "description": "The device the created session will be used on", "name": "device", "in": "query" @@ -108,24 +113,26 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/dbc.Session" + "$ref": "#/definitions/main.SessionWToken" } }, - "400": { - "description": "Invalid login body", - "schema": {} - }, "403": { "description": "Invalid password", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "404": { "description": "Account does not exists", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "422": { "description": "User does not have a password (registered via oidc, please login via oidc)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -152,21 +159,17 @@ const docTemplate = `{ "$ref": "#/definitions/main.Session" } }, - "400": { - "description": "Invalid session id", - "schema": {} - }, "401": { "description": "Missing jwt token", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "403": { "description": "Invalid jwt token (or expired)", - "schema": {} - }, - "404": { - "description": "Session not found with specified id (if not using the /current route)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -185,11 +188,12 @@ const docTemplate = `{ "tags": [ "sessions" ], - "summary": "Logout", + "summary": "Delete other session", "parameters": [ { "type": "string", "format": "uuid", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397", "description": "The id of the session to delete", "name": "id", "in": "path", @@ -203,21 +207,17 @@ const docTemplate = `{ "$ref": "#/definitions/main.Session" } }, - "400": { - "description": "Invalid session id", - "schema": {} - }, - "401": { - "description": "Missing jwt token", - "schema": {} - }, - "403": { - "description": "Invalid jwt token (or expired)", - "schema": {} - }, "404": { "description": "Session not found with specified id (if not using the /current route)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid session id", + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -245,9 +245,8 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "format": "uuid", "description": "used for pagination.", - "name": "afterId", + "name": "after", "in": "query" } ], @@ -255,12 +254,14 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.User" + "$ref": "#/definitions/main.Page-main_User" } }, - "400": { + "422": { "description": "Invalid after id", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -279,6 +280,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", + "example": "android", "description": "The device the created session will be used on", "name": "device", "in": "query" @@ -296,16 +298,20 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/dbc.Session" + "$ref": "#/definitions/main.SessionWToken" } }, - "400": { - "description": "Invalid register body", - "schema": {} - }, "409": { "description": "Duplicated email or username", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid register body", + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -334,11 +340,15 @@ const docTemplate = `{ }, "401": { "description": "Missing jwt token", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "403": { "description": "Invalid jwt token (or expired)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -405,7 +415,15 @@ const docTemplate = `{ }, "404": { "description": "No user with the given id found", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid id (not a uuid)", + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -446,45 +464,50 @@ const docTemplate = `{ }, "404": { "description": "Invalid user id", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } } }, "definitions": { - "dbc.Session": { + "main.JwkSet": { "type": "object", "properties": { - "createdDate": { - "type": "string" - }, - "device": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastUsed": { - "type": "string" - }, - "pk": { - "type": "integer" - }, - "token": { - "type": "string" - }, - "userPk": { - "type": "integer" - } - } - }, - "main.Info": { - "type": "object", - "properties": { - "publicKey": { - "description": "The public key used to sign jwt tokens. It can be used by your services to check if the jwt is valid.", - "type": "string" + "keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "e": { + "type": "string", + "example": "AQAB" + }, + "key_ops": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[verify]" + ] + }, + "kty": { + "type": "string", + "example": "RSA" + }, + "n": { + "type": "string", + "example": "oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs" + }, + "use": { + "type": "string", + "example": "sig" + } + } + } } } }, @@ -493,7 +516,22 @@ const docTemplate = `{ "properties": { "token": { "description": "The jwt token you can use for all authorized call to either keibi or other services.", - "type": "string" + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + } + } + }, + "main.KError": { + "type": "object", + "properties": { + "details": {}, + "message": { + "type": "string", + "example": "No user found with this id" + }, + "status": { + "type": "integer", + "example": 404 } } }, @@ -506,11 +544,13 @@ const docTemplate = `{ "properties": { "login": { "description": "Either the email or the username.", - "type": "string" + "type": "string", + "example": "zoriya" }, "password": { "description": "Password of the account.", - "type": "string" + "type": "string", + "example": "password1234" } } }, @@ -519,16 +559,38 @@ const docTemplate = `{ "properties": { "id": { "description": "Id of this oidc handle.", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "profileUrl": { "description": "Link to the profile of the user on the external service. Null if unknown or irrelevant.", "type": "string", - "format": "url" + "format": "url", + "example": "https://myanimelist.net/profile/zoriya" }, "username": { "description": "Username of the user on the external service.", - "type": "string" + "type": "string", + "example": "zoriya" + } + } + }, + "main.Page-main_User": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/main.User" + } + }, + "next": { + "type": "string", + "example": "https://kyoo.zoriya.dev/auth/users?after=aoeusth" + }, + "this": { + "type": "string", + "example": "https://kyoo.zoriya.dev/auth/users" } } }, @@ -543,15 +605,18 @@ const docTemplate = `{ "email": { "description": "Valid email that could be used for forgotten password requests. Can be used for login.", "type": "string", - "format": "email" + "format": "email", + "example": "kyoo@zoriya.dev" }, "password": { "description": "Password to use.", - "type": "string" + "type": "string", + "example": "password1234" }, "username": { "description": "Username of the new account, can't contain @ signs. Can be used for login.", - "type": "string" + "type": "string", + "example": "zoriya" } } }, @@ -560,19 +625,52 @@ const docTemplate = `{ "properties": { "createdDate": { "description": "When was the session first opened", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "device": { "description": "Device that created the session.", - "type": "string" + "type": "string", + "example": "Web - Firefox" }, "id": { "description": "Unique id of this session. Can be used for calls to DELETE", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "lastUsed": { "description": "Last date this session was used to access a service.", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + } + } + }, + "main.SessionWToken": { + "type": "object", + "properties": { + "createdDate": { + "description": "When was the session first opened", + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + }, + "device": { + "description": "Device that created the session.", + "type": "string", + "example": "Web - Firefox" + }, + "id": { + "description": "Unique id of this session. Can be used for calls to DELETE", + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" + }, + "lastUsed": { + "description": "Last date this session was used to access a service.", + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + }, + "token": { + "type": "string", + "example": "lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA==" } } }, @@ -584,24 +682,31 @@ const docTemplate = `{ "type": "object", "additionalProperties": { "type": "string" + }, + "example": { + "isAdmin": " true" } }, "createdDate": { "description": "When was this account created?", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "email": { "description": "Email of the user. Can be used as a login.", "type": "string", - "format": "email" + "format": "email", + "example": "kyoo@zoriya.dev" }, "id": { "description": "Id of the user.", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "lastSeen": { "description": "When was the last time this account made any authorized request?", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "oidc": { "description": "List of other login method available for this user. Access tokens wont be returned here.", @@ -612,7 +717,8 @@ const docTemplate = `{ }, "username": { "description": "Username of the user. Can be used as a login.", - "type": "string" + "type": "string", + "example": "zoriya" } } } diff --git a/auth/docs/swagger.json b/auth/docs/swagger.json index a41e8589..2215fcf0 100644 --- a/auth/docs/swagger.json +++ b/auth/docs/swagger.json @@ -16,21 +16,21 @@ "host": "kyoo.zoriya.dev", "basePath": "/auth", "paths": { - "/info": { + "/.well-known/jwks.json": { "get": { - "description": "Get info like the public key used to sign the jwts.", + "description": "Get the jwks info, used to validate jwts.", "produces": [ "application/json" ], "tags": [ "jwt" ], - "summary": "Info", + "summary": "Jwks", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.Info" + "$ref": "#/definitions/main.JwkSet" } } } @@ -56,15 +56,19 @@ "description": "OK", "schema": { "$ref": "#/definitions/main.Jwt" + }, + "headers": { + "Authorization": { + "type": "string", + "description": "Jwt (same value as the returned token)" + } } }, - "401": { - "description": "Missing session token", - "schema": {} - }, "403": { "description": "Invalid session token (or expired)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -85,6 +89,7 @@ "parameters": [ { "type": "string", + "example": "android tv", "description": "The device the created session will be used on", "name": "device", "in": "query" @@ -102,24 +107,26 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/dbc.Session" + "$ref": "#/definitions/main.SessionWToken" } }, - "400": { - "description": "Invalid login body", - "schema": {} - }, "403": { "description": "Invalid password", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "404": { "description": "Account does not exists", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "422": { "description": "User does not have a password (registered via oidc, please login via oidc)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -146,21 +153,17 @@ "$ref": "#/definitions/main.Session" } }, - "400": { - "description": "Invalid session id", - "schema": {} - }, "401": { "description": "Missing jwt token", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "403": { "description": "Invalid jwt token (or expired)", - "schema": {} - }, - "404": { - "description": "Session not found with specified id (if not using the /current route)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -179,11 +182,12 @@ "tags": [ "sessions" ], - "summary": "Logout", + "summary": "Delete other session", "parameters": [ { "type": "string", "format": "uuid", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397", "description": "The id of the session to delete", "name": "id", "in": "path", @@ -197,21 +201,17 @@ "$ref": "#/definitions/main.Session" } }, - "400": { - "description": "Invalid session id", - "schema": {} - }, - "401": { - "description": "Missing jwt token", - "schema": {} - }, - "403": { - "description": "Invalid jwt token (or expired)", - "schema": {} - }, "404": { "description": "Session not found with specified id (if not using the /current route)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid session id", + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -239,9 +239,8 @@ "parameters": [ { "type": "string", - "format": "uuid", "description": "used for pagination.", - "name": "afterId", + "name": "after", "in": "query" } ], @@ -249,12 +248,14 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/main.User" + "$ref": "#/definitions/main.Page-main_User" } }, - "400": { + "422": { "description": "Invalid after id", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -273,6 +274,7 @@ "parameters": [ { "type": "string", + "example": "android", "description": "The device the created session will be used on", "name": "device", "in": "query" @@ -290,16 +292,20 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/dbc.Session" + "$ref": "#/definitions/main.SessionWToken" } }, - "400": { - "description": "Invalid register body", - "schema": {} - }, "409": { "description": "Duplicated email or username", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid register body", + "schema": { + "$ref": "#/definitions/main.KError" + } } } } @@ -328,11 +334,15 @@ }, "401": { "description": "Missing jwt token", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } }, "403": { "description": "Invalid jwt token (or expired)", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -399,7 +409,15 @@ }, "404": { "description": "No user with the given id found", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } + }, + "422": { + "description": "Invalid id (not a uuid)", + "schema": { + "$ref": "#/definitions/main.KError" + } } } }, @@ -440,45 +458,50 @@ }, "404": { "description": "Invalid user id", - "schema": {} + "schema": { + "$ref": "#/definitions/main.KError" + } } } } } }, "definitions": { - "dbc.Session": { + "main.JwkSet": { "type": "object", "properties": { - "createdDate": { - "type": "string" - }, - "device": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastUsed": { - "type": "string" - }, - "pk": { - "type": "integer" - }, - "token": { - "type": "string" - }, - "userPk": { - "type": "integer" - } - } - }, - "main.Info": { - "type": "object", - "properties": { - "publicKey": { - "description": "The public key used to sign jwt tokens. It can be used by your services to check if the jwt is valid.", - "type": "string" + "keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "e": { + "type": "string", + "example": "AQAB" + }, + "key_ops": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[verify]" + ] + }, + "kty": { + "type": "string", + "example": "RSA" + }, + "n": { + "type": "string", + "example": "oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs" + }, + "use": { + "type": "string", + "example": "sig" + } + } + } } } }, @@ -487,7 +510,22 @@ "properties": { "token": { "description": "The jwt token you can use for all authorized call to either keibi or other services.", - "type": "string" + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + } + } + }, + "main.KError": { + "type": "object", + "properties": { + "details": {}, + "message": { + "type": "string", + "example": "No user found with this id" + }, + "status": { + "type": "integer", + "example": 404 } } }, @@ -500,11 +538,13 @@ "properties": { "login": { "description": "Either the email or the username.", - "type": "string" + "type": "string", + "example": "zoriya" }, "password": { "description": "Password of the account.", - "type": "string" + "type": "string", + "example": "password1234" } } }, @@ -513,16 +553,38 @@ "properties": { "id": { "description": "Id of this oidc handle.", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "profileUrl": { "description": "Link to the profile of the user on the external service. Null if unknown or irrelevant.", "type": "string", - "format": "url" + "format": "url", + "example": "https://myanimelist.net/profile/zoriya" }, "username": { "description": "Username of the user on the external service.", - "type": "string" + "type": "string", + "example": "zoriya" + } + } + }, + "main.Page-main_User": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/main.User" + } + }, + "next": { + "type": "string", + "example": "https://kyoo.zoriya.dev/auth/users?after=aoeusth" + }, + "this": { + "type": "string", + "example": "https://kyoo.zoriya.dev/auth/users" } } }, @@ -537,15 +599,18 @@ "email": { "description": "Valid email that could be used for forgotten password requests. Can be used for login.", "type": "string", - "format": "email" + "format": "email", + "example": "kyoo@zoriya.dev" }, "password": { "description": "Password to use.", - "type": "string" + "type": "string", + "example": "password1234" }, "username": { "description": "Username of the new account, can't contain @ signs. Can be used for login.", - "type": "string" + "type": "string", + "example": "zoriya" } } }, @@ -554,19 +619,52 @@ "properties": { "createdDate": { "description": "When was the session first opened", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "device": { "description": "Device that created the session.", - "type": "string" + "type": "string", + "example": "Web - Firefox" }, "id": { "description": "Unique id of this session. Can be used for calls to DELETE", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "lastUsed": { "description": "Last date this session was used to access a service.", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + } + } + }, + "main.SessionWToken": { + "type": "object", + "properties": { + "createdDate": { + "description": "When was the session first opened", + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + }, + "device": { + "description": "Device that created the session.", + "type": "string", + "example": "Web - Firefox" + }, + "id": { + "description": "Unique id of this session. Can be used for calls to DELETE", + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" + }, + "lastUsed": { + "description": "Last date this session was used to access a service.", + "type": "string", + "example": "2025-03-29T18:20:05.267Z" + }, + "token": { + "type": "string", + "example": "lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA==" } } }, @@ -578,24 +676,31 @@ "type": "object", "additionalProperties": { "type": "string" + }, + "example": { + "isAdmin": " true" } }, "createdDate": { "description": "When was this account created?", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "email": { "description": "Email of the user. Can be used as a login.", "type": "string", - "format": "email" + "format": "email", + "example": "kyoo@zoriya.dev" }, "id": { "description": "Id of the user.", - "type": "string" + "type": "string", + "example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397" }, "lastSeen": { "description": "When was the last time this account made any authorized request?", - "type": "string" + "type": "string", + "example": "2025-03-29T18:20:05.267Z" }, "oidc": { "description": "List of other login method available for this user. Access tokens wont be returned here.", @@ -606,7 +711,8 @@ }, "username": { "description": "Username of the user. Can be used as a login.", - "type": "string" + "type": "string", + "example": "zoriya" } } } diff --git a/auth/docs/swagger.yaml b/auth/docs/swagger.yaml deleted file mode 100644 index 38ada431..00000000 --- a/auth/docs/swagger.yaml +++ /dev/null @@ -1,426 +0,0 @@ -basePath: /auth -definitions: - dbc.Session: - properties: - createdDate: - type: string - device: - type: string - id: - type: string - lastUsed: - type: string - pk: - type: integer - token: - type: string - userPk: - type: integer - type: object - main.Info: - properties: - publicKey: - description: The public key used to sign jwt tokens. It can be used by your - services to check if the jwt is valid. - type: string - type: object - main.Jwt: - properties: - token: - description: The jwt token you can use for all authorized call to either keibi - or other services. - type: string - type: object - main.LoginDto: - properties: - login: - description: Either the email or the username. - type: string - password: - description: Password of the account. - type: string - required: - - login - - password - type: object - main.OidcHandle: - properties: - id: - description: Id of this oidc handle. - type: string - profileUrl: - description: Link to the profile of the user on the external service. Null - if unknown or irrelevant. - format: url - type: string - username: - description: Username of the user on the external service. - type: string - type: object - main.RegisterDto: - properties: - email: - description: Valid email that could be used for forgotten password requests. - Can be used for login. - format: email - type: string - password: - description: Password to use. - type: string - username: - description: Username of the new account, can't contain @ signs. Can be used - for login. - type: string - required: - - email - - password - - username - type: object - main.Session: - properties: - createdDate: - description: When was the session first opened - type: string - device: - description: Device that created the session. - type: string - id: - description: Unique id of this session. Can be used for calls to DELETE - type: string - lastUsed: - description: Last date this session was used to access a service. - type: string - type: object - main.User: - properties: - claims: - additionalProperties: - type: string - description: List of custom claims JWT created via get /jwt will have - type: object - createdDate: - description: When was this account created? - type: string - email: - description: Email of the user. Can be used as a login. - format: email - type: string - id: - description: Id of the user. - type: string - lastSeen: - description: When was the last time this account made any authorized request? - type: string - oidc: - additionalProperties: - $ref: '#/definitions/main.OidcHandle' - description: List of other login method available for this user. Access tokens - wont be returned here. - type: object - username: - description: Username of the user. Can be used as a login. - type: string - type: object -host: kyoo.zoriya.dev -info: - contact: - name: Repository - url: https://github.com/zoriya/kyoo - description: Auth system made for kyoo. - license: - name: GPL-3.0 - url: https://www.gnu.org/licenses/gpl-3.0.en.html - title: Keibi - Kyoo's auth - version: "1.0" -paths: - /info: - get: - description: Get info like the public key used to sign the jwts. - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.Info' - summary: Info - tags: - - jwt - /jwt: - get: - description: Convert a session token to a short lived JWT. - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.Jwt' - "401": - description: Missing session token - schema: {} - "403": - description: Invalid session token (or expired) - schema: {} - security: - - Token: [] - summary: Get JWT - tags: - - jwt - /sessions: - post: - consumes: - - application/json - description: Login to your account and open a session - parameters: - - description: The device the created session will be used on - in: query - name: device - type: string - - description: Account informations - in: body - name: login - schema: - $ref: '#/definitions/main.LoginDto' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/dbc.Session' - "400": - description: Invalid login body - schema: {} - "403": - description: Invalid password - schema: {} - "404": - description: Account does not exists - schema: {} - "422": - description: User does not have a password (registered via oidc, please - login via oidc) - schema: {} - summary: Login - tags: - - sessions - /sessions/{id}: - delete: - description: Delete a session and logout - parameters: - - description: The id of the session to delete - format: uuid - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.Session' - "400": - description: Invalid session id - schema: {} - "401": - description: Missing jwt token - schema: {} - "403": - description: Invalid jwt token (or expired) - schema: {} - "404": - description: Session not found with specified id (if not using the /current - route) - schema: {} - security: - - Jwt: [] - summary: Logout - tags: - - sessions - /sessions/current: - delete: - description: Delete a session and logout - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.Session' - "400": - description: Invalid session id - schema: {} - "401": - description: Missing jwt token - schema: {} - "403": - description: Invalid jwt token (or expired) - schema: {} - "404": - description: Session not found with specified id (if not using the /current - route) - schema: {} - security: - - Jwt: [] - summary: Logout - tags: - - sessions - /users: - get: - consumes: - - application/json - description: List all users existing in this instance. - parameters: - - description: used for pagination. - format: uuid - in: query - name: afterId - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.User' - "400": - description: Invalid after id - schema: {} - security: - - Jwt: - - users.read - summary: List all users - tags: - - users - post: - consumes: - - application/json - description: Register as a new user and open a session for it - parameters: - - description: The device the created session will be used on - in: query - name: device - type: string - - description: Registration informations - in: body - name: user - schema: - $ref: '#/definitions/main.RegisterDto' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/dbc.Session' - "400": - description: Invalid register body - schema: {} - "409": - description: Duplicated email or username - schema: {} - summary: Register - tags: - - users - /users/{id}: - delete: - consumes: - - application/json - description: Delete an account and all it's sessions. - parameters: - - description: User id of the user to delete - format: uuid - in: path - name: id - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.User' - "404": - description: Invalid user id - schema: {} - security: - - Jwt: - - users.delete - summary: Delete user - tags: - - users - get: - description: Get informations about a user from it's id - parameters: - - description: The id of the user - format: uuid - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.User' - "404": - description: No user with the given id found - schema: {} - security: - - Jwt: - - users.read - summary: Get user - tags: - - users - /users/me: - delete: - consumes: - - application/json - description: Delete your account and all your sessions - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.User' - security: - - Jwt: [] - summary: Delete self - tags: - - users - get: - description: Get informations about the currently connected user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/main.User' - "401": - description: Missing jwt token - schema: {} - "403": - description: Invalid jwt token (or expired) - schema: {} - security: - - Jwt: [] - summary: Get me - tags: - - users -securityDefinitions: - Jwt: - in: header - name: Authorization - type: apiKey - Token: - in: header - name: Authorization - type: apiKey -swagger: "2.0" diff --git a/auth/go.mod b/auth/go.mod index 3b15b790..bce64077 100644 --- a/auth/go.mod +++ b/auth/go.mod @@ -8,24 +8,22 @@ require ( github.com/alexedwards/argon2id v1.0.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.7.3 - github.com/labstack/echo-jwt/v4 v4.2.0 + github.com/jackc/pgx/v5 v5.7.4 + github.com/labstack/echo-jwt/v4 v4.3.1 github.com/labstack/echo/v4 v4.13.3 github.com/lestrrat-go/jwx v1.2.30 - github.com/otaxhu/problem v1.4.0 github.com/swaggo/echo-swagger v1.4.1 github.com/swaggo/swag v1.16.4 ) require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect - github.com/otaxhu/type-mismatch-encoding v0.0.0-20241118152201-1861af90dd01 // indirect github.com/pkg/errors v0.9.1 // indirect ) @@ -39,7 +37,7 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.25.0 + github.com/go-playground/validator/v10 v10.26.0 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -58,7 +56,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/auth/go.sum b/auth/go.sum index f930ac64..dbbc6bcb 100644 --- a/auth/go.sum +++ b/auth/go.sum @@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -47,8 +49,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -70,6 +76,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo= github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -80,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= +github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= +github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -171,6 +181,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/auth/jwt.go b/auth/jwt.go index 21edaa63..41d43d29 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -15,7 +15,7 @@ import ( type Jwt struct { // The jwt token you can use for all authorized call to either keibi or other services. - Token string `json:"token"` + Token *string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"` } // @Summary Get JWT @@ -24,22 +24,64 @@ type Jwt struct { // @Produce json // @Security Token // @Success 200 {object} Jwt -// @Failure 401 {object} problem.Problem "Missing session token" -// @Failure 403 {object} problem.Problem "Invalid session token (or expired)" +// @Failure 403 {object} KError "Invalid session token (or expired)" +// @Header 200 {string} Authorization "Jwt (same value as the returned token)" // @Router /jwt [get] func (h *Handler) CreateJwt(c echo.Context) error { auth := c.Request().Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing session token") - } - token := auth[len("Bearer "):] + var jwt *string + if !strings.HasPrefix(auth, "Bearer ") { + jwt = h.createGuestJwt() + } else { + token := auth[len("Bearer "):] + + tkn, err := h.createJwt(token) + if err != nil { + return err + } + jwt = &tkn + } + + if jwt != nil { + c.Response().Header().Add("Authorization", fmt.Sprintf("Bearer %s", *jwt)) + } + return c.JSON(http.StatusOK, Jwt{ + Token: jwt, + }) +} + +func (h *Handler) createGuestJwt() *string { + if h.config.GuestClaims == nil { + return nil + } + + claims := maps.Clone(h.config.GuestClaims) + claims["username"] = "guest" + claims["sub"] = "guest" + claims["sid"] = "guest" + claims["iss"] = h.config.PublicUrl + claims["iat"] = &jwt.NumericDate{ + Time: time.Now().UTC(), + } + claims["exp"] = &jwt.NumericDate{ + Time: time.Now().UTC().Add(time.Hour), + } + jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + t, err := jwt.SignedString(h.config.JwtPrivateKey) + if err != nil { + return nil + } + return &t +} + +func (h *Handler) createJwt(token string) (string, error) { session, err := h.db.GetUserFromToken(context.Background(), token) if err != nil { - return echo.NewHTTPError(http.StatusForbidden, "Invalid token") + return "", echo.NewHTTPError(http.StatusForbidden, "Invalid token") } if session.LastUsed.Add(h.config.ExpirationDelay).Compare(time.Now().UTC()) < 0 { - return echo.NewHTTPError(http.StatusForbidden, "Token has expired") + return "", echo.NewHTTPError(http.StatusForbidden, "Token has expired") } go func() { @@ -61,19 +103,27 @@ func (h *Handler) CreateJwt(c echo.Context) error { jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) t, err := jwt.SignedString(h.config.JwtPrivateKey) if err != nil { - return err + return "", err + } + return t, nil +} + +// only used for the swagger doc +type JwkSet struct { + Keys []struct { + E string `json:"e" example:"AQAB"` + KeyOps []string `json:"key_ops" example:"[verify]"` + Kty string `json:"kty" example:"RSA"` + N string `json:"n" example:"oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs"` + Use string `json:"use" example:"sig"` } - c.Response().Header().Add("Authorization", fmt.Sprintf("Bearer %s", t)) - return c.JSON(http.StatusOK, Jwt{ - Token: t, - }) } // @Summary Jwks // @Description Get the jwks info, used to validate jwts. // @Tags jwt // @Produce json -// @Success 200 {object} jwk.Key +// @Success 200 {object} JwkSet "OK" // @Router /.well-known/jwks.json [get] func (h *Handler) GetJwks(c echo.Context) error { key, err := jwk.New(h.config.JwtPublicKey) diff --git a/auth/kerror.go b/auth/kerror.go new file mode 100644 index 00000000..a2104ea6 --- /dev/null +++ b/auth/kerror.go @@ -0,0 +1,7 @@ +package main + +type KError struct { + Status int `json:"status" example:"404"` + Message string `json:"message" example:"No user found with this id"` + Details any `json:"details"` +} diff --git a/auth/main.go b/auth/main.go index c6abeb09..05d05d26 100644 --- a/auth/main.go +++ b/auth/main.go @@ -2,13 +2,14 @@ package main import ( "context" + "encoding/base64" "errors" "fmt" "net/http" "os" "strconv" + "strings" - "github.com/otaxhu/problem" "github.com/zoriya/kyoo/keibi/dbc" _ "github.com/zoriya/kyoo/keibi/docs" @@ -31,12 +32,19 @@ func ErrorHandler(err error, c echo.Context) { if he, ok := err.(*echo.HTTPError); ok { code = he.Code message = fmt.Sprint(he.Message) + + if message == "missing or malformed jwt" { + code = http.StatusUnauthorized + } } else { c.Logger().Error(err) } - ret := problem.NewMap(code, message) - c.JSON(code, ret) + c.JSON(code, KError{ + Status: code, + Message: message, + Details: nil, + }) } type Validator struct { @@ -121,6 +129,35 @@ type Handler struct { config *Configuration } +func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + auth := c.Request().Header.Get("Authorization") + var jwt *string + + if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + jwt = h.createGuestJwt() + } else { + token := auth[len("Bearer "):] + // this is only used to check if it is a session token or a jwt + _, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return next(c) + } + + tkn, err := h.createJwt(token) + if err != nil { + return err + } + jwt = &tkn + } + + if jwt != nil { + c.Request().Header.Set("Authorization", *jwt) + } + return next(c) + } +} + // @title Keibi - Kyoo's auth // @version 1.0 // @description Auth system made for kyoo. @@ -165,6 +202,7 @@ func main() { g := e.Group(conf.Prefix) r := e.Group(conf.Prefix) + r.Use(h.TokenToJwt) r.Use(echojwt.WithConfig(echojwt.Config{ SigningMethod: "RS256", SigningKey: h.config.JwtPublicKey, diff --git a/auth/page.go b/auth/page.go new file mode 100644 index 00000000..c82dffff --- /dev/null +++ b/auth/page.go @@ -0,0 +1,28 @@ +package main + +import "net/url" + +type Page[T any] struct { + Items []T `json:"items"` + This string `json:"this" example:"https://kyoo.zoriya.dev/auth/users"` + Next *string `json:"next" example:"https://kyoo.zoriya.dev/auth/users?after=aoeusth"` +} + +func NewPage(items []User, url *url.URL, limit int32) Page[User] { + this := url.String() + + var next *string + if len(items) == int(limit) && limit > 0 { + query := url.Query() + query.Set("after", items[len(items)-1].Id.String()) + url.RawQuery = query.Encode() + nextU := url.String() + next = &nextU + } + + return Page[User]{ + Items: items, + This: this, + Next: next, + } +} diff --git a/auth/robot/auth.resource b/auth/robot/auth.resource deleted file mode 100644 index 2e469ecd..00000000 --- a/auth/robot/auth.resource +++ /dev/null @@ -1,43 +0,0 @@ -*** Settings *** -Documentation Common things to handle rest requests - -Library REST http://localhost:4568 - - -*** Keywords *** -Login - [Documentation] Shortcut to login with the given username for future requests - [Arguments] ${username} - &{res}= POST /sessions {"login": "${username}", "password": "password-${username}"} - Output - Integer response status 201 - String response body token - ConvertToJwt ${res.body.token} - -Register - [Documentation] Shortcut to register with the given username for future requests - [Arguments] ${username} - &{res}= POST - ... /users - ... {"username": "${username}", "password": "password-${username}", "email": "${username}@zoriya.dev"} - Output - Integer response status 201 - String response body token - ConvertToJwt ${res.body.token} - -ConvertToJwt - [Documentation] Convert a session token to a jwt and set it in the header - [Arguments] ${token} - Set Headers {"Authorization": "Bearer ${token}"} - &{res}= GET /jwt - Output - Integer response status 200 - String response body token - Set Headers {"Authorization": "Bearer ${res.body.token}"} - -Logout - [Documentation] Logout the current user, only the local client is affected. - ${res}= DELETE /sessions/current - Output - Integer response status 200 - Set Headers {"Authorization": ""} diff --git a/auth/robot/sessions.robot b/auth/robot/sessions.robot deleted file mode 100644 index 6f6b7f1b..00000000 --- a/auth/robot/sessions.robot +++ /dev/null @@ -1,36 +0,0 @@ -*** Settings *** -Documentation Tests of the /sessions route. - -Resource ./auth.resource - - -*** Test Cases *** -Bad Account - [Documentation] Login fails if user does not exist - POST /sessions {"login": "i-don-t-exist", "password": "pass"} - Output - Integer response status 404 - -Invalid password - [Documentation] Login fails if password is invalid - Register invalid-password-user - POST /sessions {"login": "invalid-password-user", "password": "pass"} - Output - Integer response status 403 - [Teardown] DELETE /users/me - -Login - [Documentation] Create a new user and login in it - Register login-user - ${res}= GET /users/me - Output - Integer response status 200 - String response body username login-user - Logout - Login login-user - ${me}= Get /users/me - Output - Output ${me} - Should Be Equal As Strings ${res["body"]} ${me["body"]} - - [Teardown] DELETE /users/me diff --git a/auth/robot/users.robot b/auth/robot/users.robot deleted file mode 100644 index 603fd1ec..00000000 --- a/auth/robot/users.robot +++ /dev/null @@ -1,33 +0,0 @@ -*** Settings *** -Documentation Tests of the /users route. -... Ensures that the user can authenticate on kyoo. - -Resource ./auth.resource - - -*** Test Cases *** -Me cant be accessed without an account - Get /users/me - Output - Integer response status 401 - -Register - [Documentation] Create a new user and login in it - Register user-1 - [Teardown] DELETE /users/me - -Register Duplicates - [Documentation] If two users tries to register with the same username, it fails - Register user-duplicate - # We can't use the `Register` keyword because it assert for success - POST /users {"username": "user-duplicate", "password": "pass", "email": "mail@zoriya.dev"} - Output - Integer response status 409 - [Teardown] DELETE /users/me - -Delete Account - [Documentation] Check if a user can delete it's account - Register I-should-be-deleted - DELETE /users/me - Output - Integer response status 200 diff --git a/auth/sessions.go b/auth/sessions.go index 1092f5fa..86a05a93 100644 --- a/auth/sessions.go +++ b/auth/sessions.go @@ -18,13 +18,18 @@ import ( type Session struct { // Unique id of this session. Can be used for calls to DELETE - Id uuid.UUID `json:"id"` + Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"` // When was the session first opened - CreatedDate time.Time `json:"createdDate"` + CreatedDate time.Time `json:"createdDate" example:"2025-03-29T18:20:05.267Z"` // Last date this session was used to access a service. - LastUsed time.Time `json:"lastUsed"` + LastUsed time.Time `json:"lastUsed" example:"2025-03-29T18:20:05.267Z"` // Device that created the session. - Device *string `json:"device"` + Device *string `json:"device" example:"Web - Firefox"` +} + +type SessionWToken struct { + Session + Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="` } func MapSession(ses *dbc.Session) Session { @@ -36,11 +41,23 @@ func MapSession(ses *dbc.Session) Session { } } +func MapSessionToken(ses *dbc.Session) SessionWToken { + return SessionWToken{ + Session: Session{ + Id: ses.Id, + CreatedDate: ses.CreatedDate, + LastUsed: ses.LastUsed, + Device: ses.Device, + }, + Token: ses.Token, + } +} + type LoginDto struct { // Either the email or the username. - Login string `json:"login" validate:"required"` + Login string `json:"login" validate:"required" example:"zoriya"` // Password of the account. - Password string `json:"password" validate:"required"` + Password string `json:"password" validate:"required" example:"password1234"` } // @Summary Login @@ -48,19 +65,18 @@ type LoginDto struct { // @Tags sessions // @Accept json // @Produce json -// @Param device query string false "The device the created session will be used on" +// @Param device query string false "The device the created session will be used on" example(android tv) // @Param login body LoginDto false "Account informations" -// @Success 201 {object} dbc.Session -// @Failure 400 {object} problem.Problem "Invalid login body" -// @Failure 403 {object} problem.Problem "Invalid password" -// @Failure 404 {object} problem.Problem "Account does not exists" -// @Failure 422 {object} problem.Problem "User does not have a password (registered via oidc, please login via oidc)" +// @Success 201 {object} SessionWToken +// @Failure 403 {object} KError "Invalid password" +// @Failure 404 {object} KError "Account does not exists" +// @Failure 422 {object} KError "User does not have a password (registered via oidc, please login via oidc)" // @Router /sessions [post] func (h *Handler) Login(c echo.Context) error { var req LoginDto err := c.Bind(&req) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error()) } if err = c.Validate(&req); err != nil { return err @@ -102,7 +118,7 @@ func (h *Handler) createSession(c echo.Context, user *User) error { } session, err := h.db.CreateSession(ctx, dbc.CreateSessionParams{ - Token: base64.StdEncoding.EncodeToString(id), + Token: base64.RawURLEncoding.EncodeToString(id), UserPk: user.Pk, Device: device, }) @@ -117,13 +133,9 @@ func (h *Handler) createSession(c echo.Context, user *User) error { // @Tags sessions // @Produce json // @Security Jwt -// @Param id path string true "The id of the session to delete" Format(uuid) // @Success 200 {object} Session -// @Failure 400 {object} problem.Problem "Invalid session id" -// @Failure 401 {object} problem.Problem "Missing jwt token" -// @Failure 403 {object} problem.Problem "Invalid jwt token (or expired)" -// @Failure 404 {object} problem.Problem "Session not found with specified id (if not using the /current route)" -// @Router /sessions/{id} [delete] +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Router /sessions/current [delete] func (h *Handler) Logout(c echo.Context) error { uid, err := GetCurrentUserId(c) @@ -135,13 +147,13 @@ func (h *Handler) Logout(c echo.Context) error { if session == "current" { sid, ok := c.Get("user").(*jwt.Token).Claims.(jwt.MapClaims)["sid"] if !ok { - return echo.NewHTTPError(400, "Missing session id") + return echo.NewHTTPError(http.StatusInternalServerError, "Missing session id") } session = sid.(string) } sid, err := uuid.Parse(session) if err != nil { - return echo.NewHTTPError(400, "Invalid session id") + return echo.NewHTTPError(422, "Invalid session id") } ret, err := h.db.DeleteSession(context.Background(), dbc.DeleteSessionParams{ @@ -155,3 +167,15 @@ func (h *Handler) Logout(c echo.Context) error { } return c.JSON(200, MapSession(&ret)) } + +// @Summary Delete other session +// @Description Delete a session and logout +// @Tags sessions +// @Produce json +// @Security Jwt +// @Param id path string true "The id of the session to delete" Format(uuid) Example(e05089d6-9179-4b5b-a63e-94dd5fc2a397) +// @Success 200 {object} Session +// @Failure 404 {object} KError "Session not found with specified id (if not using the /current route)" +// @Failure 422 {object} KError "Invalid session id" +// @Router /sessions/{id} [delete] +func DocOnly() {} diff --git a/auth/sql/migrations/000002_config.down.sql b/auth/sql/migrations/000002_config.down.sql deleted file mode 100644 index 2fd15f61..00000000 --- a/auth/sql/migrations/000002_config.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; - -drop table config; - -commit; diff --git a/auth/sql/migrations/000002_config.up.sql b/auth/sql/migrations/000002_config.up.sql deleted file mode 100644 index 7d9e4b72..00000000 --- a/auth/sql/migrations/000002_config.up.sql +++ /dev/null @@ -1,8 +0,0 @@ -begin; - -create table config( - key varchar(256) not null primary key, - value text not null -); - -commit; diff --git a/auth/sql/migrations/000003_sessions.down.sql b/auth/sql/migrations/000002_sessions.down.sql similarity index 100% rename from auth/sql/migrations/000003_sessions.down.sql rename to auth/sql/migrations/000002_sessions.down.sql diff --git a/auth/sql/migrations/000003_sessions.up.sql b/auth/sql/migrations/000002_sessions.up.sql similarity index 100% rename from auth/sql/migrations/000003_sessions.up.sql rename to auth/sql/migrations/000002_sessions.up.sql diff --git a/auth/sql/queries/config.sql b/auth/sql/queries/config.sql deleted file mode 100644 index 3f3db9d5..00000000 --- a/auth/sql/queries/config.sql +++ /dev/null @@ -1,21 +0,0 @@ --- name: LoadConfig :many -select - * -from - config; - --- name: SaveConfig :one -insert into config(key, value) - values ($1, $2) -on conflict (key) - do update set - value = excluded.value - returning - *; - --- name: DeleteConfig :one -delete from config -where key = $1 -returning - *; - diff --git a/auth/sql/queries/users.sql b/auth/sql/queries/users.sql index c57315ec..6881c861 100644 --- a/auth/sql/queries/users.sql +++ b/auth/sql/queries/users.sql @@ -51,7 +51,15 @@ where -- name: CreateUser :one insert into users(username, email, password, claims) - values ($1, $2, $3, $4) + values ($1, $2, $3, case when not exists ( + select + * + from + users) then + sqlc.arg(first_claims)::jsonb + else + sqlc.arg(claims)::jsonb + end) returning *; diff --git a/auth/sqlc.yaml b/auth/sqlc.yaml index 3bc35e05..2c48d06e 100644 --- a/auth/sqlc.yaml +++ b/auth/sqlc.yaml @@ -27,6 +27,9 @@ sql: go_type: import: "github.com/google/uuid" type: "UUID" + - db_type: "jsonb" + go_type: + type: "interface{}" - column: "users.claims" go_type: import: "github.com/golang-jwt/jwt/v5" diff --git a/auth/tests/basic.hurl b/auth/tests/basic.hurl new file mode 100644 index 00000000..a3488fe1 --- /dev/null +++ b/auth/tests/basic.hurl @@ -0,0 +1,19 @@ +# Bad Account (login fails if user does not exist) +POST {{host}}/sessions +{ + "login": "i-don-t-exist", + "password": "pass" +} +HTTP 404 + +# Invalid username +POST {{host}}/sessions +{ + "login": "invalid-username-user", + "password": "pass" +} +HTTP 404 + +# Me cant be accessed without an account +GET {{host}}/users/me +HTTP 401 diff --git a/auth/tests/invalid-password.hurl b/auth/tests/invalid-password.hurl new file mode 100644 index 00000000..7817a09a --- /dev/null +++ b/auth/tests/invalid-password.hurl @@ -0,0 +1,64 @@ +# Register a user for invalid password test +POST {{host}}/users +{ + "username": "login-user", + "password": "password-login-user", + "email": "invalid-password-user@zoriya.dev" +} +HTTP 201 +[Captures] +token: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token}} +HTTP 200 +[Captures] +jwt: jsonpath "$.token" + +GET {{host}}/users/me +Authorization: Bearer {{jwt}} +HTTP 200 +[Captures] +register_info: body +[Asserts] +jsonpath "$.username" == "login-user" + +DELETE {{host}}/sessions/current +Authorization: Bearer {{jwt}} +HTTP 200 + +# Ensure we can login again & /users/me is the same +POST {{host}}/sessions +{ + "login": "login-user", + "password": "password-login-user" +} +HTTP 201 +[Captures] +token: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token}} +HTTP 200 +[Captures] +jwt: jsonpath "$.token" + +GET {{host}}/users/me +Authorization: Bearer {{jwt}} +HTTP 200 +[Asserts] +jsonpath "$.username" == "login-user" +body == {{register_info}} + + +# Invalid password login +POST {{host}}/sessions +{ + "login": "login-user", + "password": "pass-invalid" +} +HTTP 403 + +DELETE {{host}}/users/me +Authorization: Bearer {{jwt}} +HTTP 200 diff --git a/auth/tests/users.hurl b/auth/tests/users.hurl new file mode 100644 index 00000000..8e840a12 --- /dev/null +++ b/auth/tests/users.hurl @@ -0,0 +1,41 @@ +# Setup +POST {{host}}/users +{ + "username": "user-1", + "password": "password-user-1", + "email": "user-1@zoriya.dev" +} +HTTP 201 +[Captures] +token: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token}} +HTTP 200 +[Captures] +jwt: jsonpath "$.token" + + +# Duplicates usernames +POST {{host}}/users +{ + "username": "user-1", + "password": "password-user-duplicate", + "email": "user-duplicate@zoriya.dev" +} +HTTP 409 + + +# Duplicates email +POST {{host}}/users +{ + "username": "user-duplicate", + "password": "pass", + "email": "user-1@zoriya.dev" +} +HTTP 409 + + +DELETE {{host}}/users/me +Authorization: Bearer {{jwt}} +HTTP 200 diff --git a/auth/users.go b/auth/users.go index 31ee799c..53589652 100644 --- a/auth/users.go +++ b/auth/users.go @@ -18,37 +18,37 @@ type User struct { // Primary key in database Pk int32 `json:"-"` // Id of the user. - Id uuid.UUID `json:"id"` + Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"` // Username of the user. Can be used as a login. - Username string `json:"username"` + Username string `json:"username" example:"zoriya"` // Email of the user. Can be used as a login. - Email string `json:"email" format:"email"` + Email string `json:"email" format:"email" example:"kyoo@zoriya.dev"` // When was this account created? - CreatedDate time.Time `json:"createdDate"` + CreatedDate time.Time `json:"createdDate" example:"2025-03-29T18:20:05.267Z"` // When was the last time this account made any authorized request? - LastSeen time.Time `json:"lastSeen"` + LastSeen time.Time `json:"lastSeen" example:"2025-03-29T18:20:05.267Z"` // List of custom claims JWT created via get /jwt will have - Claims jwt.MapClaims `json:"claims"` + Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` // List of other login method available for this user. Access tokens wont be returned here. Oidc map[string]OidcHandle `json:"oidc,omitempty"` } type OidcHandle struct { // Id of this oidc handle. - Id string `json:"id"` + Id string `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"` // Username of the user on the external service. - Username string `json:"username"` + Username string `json:"username" example:"zoriya"` // Link to the profile of the user on the external service. Null if unknown or irrelevant. - ProfileUrl *string `json:"profileUrl" format:"url"` + ProfileUrl *string `json:"profileUrl" format:"url" example:"https://myanimelist.net/profile/zoriya"` } type RegisterDto struct { // Username of the new account, can't contain @ signs. Can be used for login. - Username string `json:"username" validate:"required,excludes=@"` + Username string `json:"username" validate:"required,excludes=@" example:"zoriya"` // Valid email that could be used for forgotten password requests. Can be used for login. - Email string `json:"email" validate:"required,email" format:"email"` + Email string `json:"email" validate:"required,email" format:"email" example:"kyoo@zoriya.dev"` // Password to use. - Password string `json:"password" validate:"required"` + Password string `json:"password" validate:"required" example:"password1234"` } func MapDbUser(user *dbc.User) User { @@ -78,9 +78,9 @@ func MapOidc(oidc *dbc.GetUserRow) OidcHandle { // @Accept json // @Produce json // @Security Jwt[users.read] -// @Param afterId query string false "used for pagination." Format(uuid) -// @Success 200 {object} User[] -// @Failure 400 {object} problem.Problem "Invalid after id" +// @Param after query string false "used for pagination." +// @Success 200 {object} Page[User] +// @Failure 422 {object} KError "Invalid after id" // @Router /users [get] func (h *Handler) ListUsers(c echo.Context) error { err := CheckPermissions(c, []string{"user.read"}) @@ -90,7 +90,7 @@ func (h *Handler) ListUsers(c echo.Context) error { ctx := context.Background() limit := int32(20) - id := c.Param("afterId") + id := c.Param("after") var users []dbc.User if id == "" { @@ -98,7 +98,7 @@ func (h *Handler) ListUsers(c echo.Context) error { } else { uid, uerr := uuid.Parse(id) if uerr != nil { - return echo.NewHTTPError(400, "Invalid `afterId` parameter, uuid was expected") + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Invalid `after` parameter, uuid was expected") } users, err = h.db.GetAllUsersAfter(ctx, dbc.GetAllUsersAfterParams{ Limit: limit, @@ -114,8 +114,7 @@ func (h *Handler) ListUsers(c echo.Context) error { for _, user := range users { ret = append(ret, MapDbUser(&user)) } - // TODO: switch to a Page - return c.JSON(200, ret) + return c.JSON(200, NewPage(ret, c.Request().URL, limit)) } // @Summary Get user @@ -125,7 +124,8 @@ func (h *Handler) ListUsers(c echo.Context) error { // @Security Jwt[users.read] // @Param id path string true "The id of the user" Format(uuid) // @Success 200 {object} User -// @Failure 404 {object} problem.Problem "No user with the given id found" +// @Failure 404 {object} KError "No user with the given id found" +// @Failure 422 {object} KError "Invalid id (not a uuid)" // @Router /users/{id} [get] func (h *Handler) GetUser(c echo.Context) error { err := CheckPermissions(c, []string{"user.read"}) @@ -135,7 +135,7 @@ func (h *Handler) GetUser(c echo.Context) error { id, err := uuid.Parse(c.Param("id")) if err != nil { - return echo.NewHTTPError(400, "Invalid id") + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Invalid id") } dbuser, err := h.db.GetUser(context.Background(), id) if err != nil { @@ -158,8 +158,8 @@ func (h *Handler) GetUser(c echo.Context) error { // @Produce json // @Security Jwt // @Success 200 {object} User -// @Failure 401 {object} problem.Problem "Missing jwt token" -// @Failure 403 {object} problem.Problem "Invalid jwt token (or expired)" +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Router /users/me [get] func (h *Handler) GetMe(c echo.Context) error { id, err := GetCurrentUserId(c) @@ -186,17 +186,17 @@ func (h *Handler) GetMe(c echo.Context) error { // @Tags users // @Accept json // @Produce json -// @Param device query string false "The device the created session will be used on" +// @Param device query string false "The device the created session will be used on" Example(android) // @Param user body RegisterDto false "Registration informations" -// @Success 201 {object} dbc.Session -// @Failure 400 {object} problem.Problem "Invalid register body" -// @Success 409 {object} problem.Problem "Duplicated email or username" +// @Success 201 {object} SessionWToken +// @Success 409 {object} KError "Duplicated email or username" +// @Failure 422 {object} KError "Invalid register body" // @Router /users [post] func (h *Handler) Register(c echo.Context) error { var req RegisterDto err := c.Bind(&req) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error()) } if err = c.Validate(&req); err != nil { return err @@ -208,10 +208,11 @@ func (h *Handler) Register(c echo.Context) error { } duser, err := h.db.CreateUser(context.Background(), dbc.CreateUserParams{ - Username: req.Username, - Email: req.Email, - Password: &pass, - Claims: h.config.DefaultClaims, + Username: req.Username, + Email: req.Email, + Password: &pass, + Claims: h.config.DefaultClaims, + FirstClaims: h.config.FirstUserClaims, }) if ErrIs(err, pgerrcode.UniqueViolation) { return echo.NewHTTPError(409, "Email or username already taken") @@ -230,8 +231,8 @@ func (h *Handler) Register(c echo.Context) error { // @Security Jwt[users.delete] // @Param id path string false "User id of the user to delete" Format(uuid) // @Success 200 {object} User -// @Failure 404 {object} problem.Problem "Invalid id format" -// @Failure 404 {object} problem.Problem "Invalid user id" +// @Failure 404 {object} KError "Invalid id format" +// @Failure 404 {object} KError "Invalid user id" // @Router /users/{id} [delete] func (h *Handler) DeleteUser(c echo.Context) error { uid, err := uuid.Parse(c.Param("id")) diff --git a/auth/utils.go b/auth/utils.go index 38753bc3..20b8c377 100644 --- a/auth/utils.go +++ b/auth/utils.go @@ -19,7 +19,7 @@ func GetCurrentUserId(c echo.Context) (uuid.UUID, error) { } sub, err := user.Claims.GetSubject() if err != nil { - return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrive subject") + return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrieve subject") } ret, err := uuid.Parse(sub) if err != nil { diff --git a/docker-compose.dev-v5.yml b/docker-compose.dev-v5.yml index ab734ca1..f1d8a1eb 100644 --- a/docker-compose.dev-v5.yml +++ b/docker-compose.dev-v5.yml @@ -60,8 +60,7 @@ services: - ./auth:/app labels: - "traefik.enable=true" - - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`)" - - "traefik.http.routers.auth.rule=PathPrefix(`/.well-known/`)" + - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)" api: build: @@ -71,8 +70,6 @@ services: depends_on: postgres: condition: service_healthy - auth: - condition: service_healthy volumes: - ./api:/app - /app/node_modules @@ -80,11 +77,12 @@ services: - "3567:3567" environment: - KYOO_PREFIX=/api + - JWT_ISSUER=${PUBLIC_URL} env_file: - ./.env labels: - "traefik.enable=true" - - "traefik.http.routers.api.rule=PathPrefix(`/api/`)" + - "traefik.http.routers.api.rule=PathPrefix(`/api/`) || PathPrefix(`/swagger`)" - "traefik.http.routers.api.middlewares=phantom-token" - "traefik.http.middlewares.phantom-token.forwardauth.address=http://auth:4568/auth/jwt" - "traefik.http.middlewares.phantom-token.forwardauth.authRequestHeaders=Authorization,X-Api-Key" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f9abbc78..00000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -robotframework -RESTinstance diff --git a/shell.nix b/shell.nix index 84eefc02..b7ce3bfb 100644 --- a/shell.nix +++ b/shell.nix @@ -11,9 +11,6 @@ dataclasses-json msgspec langcodes - - # robotframework - # restinstance needs to be packaged ]); dotnet = with pkgs.dotnetCorePackages; combinePackages [ @@ -40,11 +37,11 @@ in go-migrate sqlc go-swag - robotframework-tidy bun pkg-config node-gyp vips + hurl ]; DOTNET_ROOT = "${dotnet}";