diff --git a/.env.example b/.env.example index f15219f1..f81f9357 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,7 @@ TVDB_PIN= # 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:5000 +PUBLIC_URL=http://localhost:8901 # Use a builtin oidc service (google, discord, trakt, or simkl): # When you create a client_id, secret combo you may be asked for a redirect url. You need to specify https://YOUR-PUBLIC-URL/api/auth/logged/YOUR-SERVICE-NAME diff --git a/.github/workflows/robot.yml b/.github/workflows/robot.yml index 044d5e3a..ca630329 100644 --- a/.github/workflows/robot.yml +++ b/.github/workflows/robot.yml @@ -14,11 +14,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Pull images - run: | - cp .env.example .env - docker compose version - docker compose pull + - name: Robot cache + uses: actions/setup-python@v4 + with: + python-version: '3.9' + cache: 'pip' + + - run: pip install -r requirements.txt - name: Docker cache uses: satackey/action-docker-layer-caching@v0.0.11 @@ -26,18 +28,18 @@ jobs: - name: Start the service run: | - docker compose up -d back postgres traefik meilisearch --wait + cp .env.example .env + docker compose --profile v5 -f docker-compose.build.yml up -d auth postgres traefik --wait --build - name: Perform healthchecks run: | docker compose ps -a docker compose logs - wget --retry-connrefused --retry-on-http-error=502 http://localhost:8901/api/health || (docker compose logs && exit 1) + # wget --retry-connrefused --retry-on-http-error=502 http://localhost:8901/api/health || (docker compose logs && exit 1) - name: Run robot tests run: | - pip install -r back/tests/robot/requirements.txt - robot -d out back/tests/robot/ + robot -d out $(find -type d -name robot) - name: Show logs if: failure() diff --git a/.gitignore b/.gitignore index 6ed2917f..6738538d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ log.html output.xml report.html chart/charts -chart/Chart.lock \ No newline at end of file +chart/Chart.lock +tmp diff --git a/auth/.dockerignore b/auth/.dockerignore new file mode 100644 index 00000000..72546c82 --- /dev/null +++ b/auth/.dockerignore @@ -0,0 +1,12 @@ +Dockerfile* +*.md +.dockerignore +.gitignore +.env* + +# generated via sqlc +dbc/ +# genereated via swag +docs/ + +# vim: ft=gitignore diff --git a/auth/.env.example b/auth/.env.example new file mode 100644 index 00000000..012ba22f --- /dev/null +++ b/auth/.env.example @@ -0,0 +1,13 @@ +# vi: ft=sh +# shellcheck disable=SC2034 + +# Database things +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= +POSTGRES_SERVER= +POSTGRES_PORT=5432 +# Default is keibi, you can specify "disabled" to use the default search_path of the user. +# If this is not "disabled", the schema will be created (if it does not exists) and +# the search_path of the user will be ignored (only the schema specified will be used). +POSTGRES_SCHEMA=keibi diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 00000000..cb4266fd --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1,4 @@ +# generated via sqlc +dbc/ +# genereated via swag +docs/ diff --git a/auth/.swaggo b/auth/.swaggo new file mode 100644 index 00000000..3372ddb7 --- /dev/null +++ b/auth/.swaggo @@ -0,0 +1,2 @@ +replace jwt.MapClaims map[string]string +replace uuid.UUID string diff --git a/auth/Dockerfile b/auth/Dockerfile new file mode 100644 index 00000000..798b7db4 --- /dev/null +++ b/auth/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.23 AS build +WORKDIR /app + +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest +RUN go install github.com/swaggo/swag/cmd/swag@latest + +COPY go.mod go.sum ./ +RUN go mod download + +COPY sqlc.yaml ./ +COPY sql ./sql +RUN sqlc generate + +COPY . . +RUN swag init --parseDependency +RUN CGO_ENABLED=0 GOOS=linux go build -o /keibi + +FROM gcr.io/distroless/base-debian11 +WORKDIR /app +EXPOSE 4568 +USER nonroot:nonroot + +COPY --from=build /keibi /app/keibi +COPY sql ./sql +CMD ["/app/keibi"] diff --git a/auth/Dockerfile.dev b/auth/Dockerfile.dev new file mode 100644 index 00000000..ab73945b --- /dev/null +++ b/auth/Dockerfile.dev @@ -0,0 +1,16 @@ +FROM golang:1.23 AS build +WORKDIR /app + +RUN go install github.com/bokwoon95/wgo@latest +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest +RUN go install github.com/swaggo/swag/cmd/swag@latest + +COPY go.mod go.sum ./ +RUN go mod download + +# COPY sqlc.yaml ./ +# COPY sql/ ./ +# RUN sqlc generate + +EXPOSE 4568 +CMD ["wgo", "run", "-race", "."] diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 00000000..4ffb3538 --- /dev/null +++ b/auth/README.md @@ -0,0 +1,151 @@ +# Keibi + +## Features + +- Not an oauth provider/no login page (as in you don't redirect to this, you create your own auth page) +- [Phantom tokens](https://curity.io/resources/learn/phantom-token-pattern/) +- Session based tokens (valid for 30 days, reset after each use [configurable]) +- Last online/last connection stored per user (and token) +- Device used per session/token +- Username/password login +- OIDC (login via Google, Discord, Authentik, whatever) +- Custom jwt claims (for your role/permissions handling or something else) +- Api keys support +- Optionally [Federated](#federated) + +## Routes + +### Lifecycle + + +Login: + +`POST /session { login, password } -> token` +`GET /login/$provider { redirectUrl, tenant? } -> redirect` + +Register: +`POST /users { email, username, password } -> token` + +Logout +`DELETE /session` w/ optional `?session=id` +`/jwt` retrieve a jwt from an opaque token (also update last online value for session & user) + +### Profiles + +``` +Get `/users` -> user[] +Get/Put/Patch/Delete `/users/$id` (or /users/me) -> user +Get/Post/Delete `/users/$id/logo` (or /users/me/logo) -> png +``` + +Put/Patch of a user can edit the password if the `oldPassword` value is set and valid (or the user has the `users.password` permission).\ +Should require an otp from mail if no oldPassword exists (see todo). + +Put/Patch can edit custom claims (roles & permissons for example) if the user has the `users.claims` permission). + +Read others requires `users.read` permission.\ +Write/Delete requires `users.write` permission (if it's not your account). + + +POST /users is how you register. + +### Sessions + +GET `/sessions` list all of your active sessions (and devices) +POST `/sessions` is how you login +Delete `/sessions` (or `/sessions/$id`) is how you logout +GET `/users/$id/sessions` can be used by admins to list others session + +### Api keys + +``` +Get `/apikeys` +Post `/apikeys` {...nlaims} Create a new api keys with given claims +``` + +An api key can be used like an opaque token, calling /jwt with it will return a valid jwt with the claims you specified during the post request to create it. +Creating an apikeys requires the `apikey.create` permission, reading them requires the `apikey.read` permission. + +### OIDC + +``` +`/login/$provider` {redirectUrl, tenant?} +`/logged/$provider` {code, state, error} (callback called automatically, don't call it manually) +`/callback/$provider` {code, tenant?} (if called with the `Authorization` header, links account w/ provider else create a new account) (see diagram below) +`/unlink/$provider` Remove provider from current account +`/providers` -> provider[] +``` + +```mermaid +sequenceDiagram + participant App + participant Browser + participant Kyoo + participant Google + App->>Kyoo: /login/google?redirectUrl=/user-logged + Kyoo->>Browser: redirect auth.google.com?state=id=guid,url=/user-logged&redirectUrl=/logged/google + Browser-->>Google: Access login page + Google->>Browser: redirect /logged/google?code=abc&state=id=guid,url=/user-logged + Browser-->>Kyoo: access /logged/google?code=abc&state=id=guid,url=/user-logged + Kyoo->>App: redirect /user-logged?token=opaque&error= + App->>Kyoo: /callback/google?token=opaque + Kyoo->>Google: auth.google.com/token?code=abc + Google->>Kyoo: jwt token + Kyoo->>Google: auth.google.com/profile (w/ jwt) + Google->>Kyoo: profile info + Kyoo->>App: Token if user exist/was created +``` + +In the previous diagram, the code is stored by Kyoo and an opaque token is returned to the client to ensure only Kyoo's auth service can read the oauth code. + +## Federated + +You can use another instance to login via oidc you have not configured. This allows an user to login/create a profile without having an api key for the oidc service. +This won't allow you to retrive a provider's jwt token, you only get a profile with basic information from the provider. This can be usefull for self-hosted apps where +you don't want to setup ~10 api keys just for login. + + +```mermaid +sequenceDiagram + participant App + participant Browser + participant Kyoo + participant Hosted + participant Google + App->>Kyoo: /login/google?redirectUrl=/user-logged + Kyoo->>Hosted: /providers + Hosted->>Kyoo: has google = true + Kyoo->>Browser: redirect hosted.com/login/google?redirectUrl=/user-logged&tenant=kyoo.com + Browser-->>Hosted: access /login/google?redirectUrl=/user-logged&tenant=kyoo.com + Hosted->>Browser: redirect auth.google.com?state=id=guid,url=/user-logged,tenant=kyoo.com&redirectUrl=/logged/google + Browser-->>Google: Access login page + Google->>Browser: redirect hosted.com/logged/google?code=abc&state=id=guid,url=/user-logged,tenant=kyoo.com + Browser-->>Hosted: access /logged/google?code=abc&state=id=guid,url=/user-logged,tenant=kyoo.com + Hosted->>App: redirect /user-logged?token=opaque&error= + App->>Kyoo: /callback/google?token=opaque + Kyoo->>Hosted: /callback/google?token=opaque&tenant=kyoo.com + Hosted->>Google: auth.google.com/token?code=abc + Google->>Hosted: jwt token + Hosted->>Google: auth.google.com/profile (w/ jwt) + Google->>Hosted: profile info + Hosted->>Kyoo: profile info (without a jwt to access the provider) + Kyoo->>App: Token if user exist/was created +``` + +The hosted service does not store any user data during this interaction. +A `/login` requests temporally stores an id, the tenant & the redirectUrl to unsure the profile value is not stollen. This is then deleted after a `/callback` call (or on timeout). +User profile or jwt is never stored. + +## Permissions + +You might have noticed that some routes requires the user to have some permissions. +Kyoo's auth uses the custom `permissions` claim for this. +Your application is free to use this or any other way of handling permissions/roles. + +## TODO + +- Reset/forget password +- Login via qrcode/code from other device (useful for tv for example) +- LDMA? +- Mails + diff --git a/auth/config.go b/auth/config.go new file mode 100644 index 00000000..927a4827 --- /dev/null +++ b/auth/config.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/zoriya/kyoo/keibi/dbc" +) + +type Configuration struct { + JwtPrivateKey *rsa.PrivateKey + JwtPublicKey *rsa.PublicKey + Issuer string + DefaultClaims jwt.MapClaims + ExpirationDelay time.Duration +} + +var DefaultConfig = Configuration{ + Issuer: "kyoo", + DefaultClaims: 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 + } + } + + if ret.JwtPrivateKey == nil { + 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/go.mod b/auth/go.mod new file mode 100644 index 00000000..8cd5a8d3 --- /dev/null +++ b/auth/go.mod @@ -0,0 +1,62 @@ +module github.com/zoriya/kyoo/keibi + +go 1.22.5 + +require ( + github.com/alexedwards/argon2id v1.0.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/labstack/echo/v4 v4.12.0 + github.com/otaxhu/problem v0.2.0 + github.com/swaggo/echo-swagger v1.4.1 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.1.2+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // 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.22.0 + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/labstack/echo-jwt/v4 v4.2.0 + github.com/labstack/gommon v0.4.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/swaggo/files/v2 v2.0.1 // indirect + github.com/swaggo/swag v1.16.3 + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.24.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/auth/go.sum b/auth/go.sum new file mode 100644 index 00000000..6d6184f1 --- /dev/null +++ b/auth/go.sum @@ -0,0 +1,228 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= +github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.1.2+incompatible h1:AhGzR1xaQIy53qCkxARaFluI00WPGtXn0AJuoQsVYTY= +github.com/docker/docker v27.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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 v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= +github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/otaxhu/problem v0.2.0 h1:cxVSlWHPi0zn1Mvl3/SVwySnnxfpHslENU1MvouSEME= +github.com/otaxhu/problem v0.2.0/go.mod h1:bp1KCPkRRBORbIg4a/p/Sa+FuFuMHVg+iEjnWL/LMKA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= +github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= +github.com/swaggo/files/v2 v2.0.1 h1:XCVJO/i/VosCDsJu1YLpdejGsGnBE9deRMpjN4pJLHk= +github.com/swaggo/files/v2 v2.0.1/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +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.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 00000000..91aacec4 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/pem" + "maps" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +type Jwt struct { + // The jwt token you can use for all authorized call to either keibi or other services. + Token string `json:"token"` +} + +type Info struct { + // The public key used to sign jwt tokens. It can be used by your services to check if the jwt is valid. + PublicKey string `json:"publicKey"` +} + +// @Summary Get JWT +// @Description Convert a session token to a short lived JWT. +// @Tags jwt +// @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)" +// @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 "):] + + session, err := h.db.GetUserFromToken(context.Background(), token) + if err != nil { + 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") + } + + go func() { + h.db.TouchSession(context.Background(), session.Id) + h.db.TouchUser(context.Background(), session.User.Id) + }() + + claims := maps.Clone(session.User.Claims) + claims["sub"] = session.User.Id.String() + claims["sid"] = session.Id.String() + claims["iss"] = h.config.Issuer + claims["exp"] = &jwt.NumericDate{ + Time: time.Now().UTC().Add(time.Hour), + } + claims["iss"] = &jwt.NumericDate{ + Time: time.Now().UTC(), + } + jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + t, err := jwt.SignedString(h.config.JwtPrivateKey) + if err != nil { + return err + } + return c.JSON(http.StatusOK, Jwt{ + Token: t, + }) +} + +// @Summary Info +// @Description Get info like the public key used to sign the jwts. +// @Tags jwt +// @Produce json +// @Success 200 {object} Info +// @Router /info [get] +func (h *Handler) GetInfo(c echo.Context) error { + key := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(h.config.JwtPublicKey), + }, + ) + + return c.JSON(200, Info{ + PublicKey: string(key), + }) +} diff --git a/auth/main.go b/auth/main.go new file mode 100644 index 00000000..d0a1369b --- /dev/null +++ b/auth/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/otaxhu/problem" + "github.com/zoriya/kyoo/keibi/dbc" + _ "github.com/zoriya/kyoo/keibi/docs" + + "github.com/go-playground/validator/v10" + "github.com/golang-migrate/migrate/v4" + pgxd "github.com/golang-migrate/migrate/v4/database/pgx/v5" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/swaggo/echo-swagger" +) + +func ErrorHandler(err error, c echo.Context) { + code := http.StatusInternalServerError + var message string + + if he, ok := err.(*echo.HTTPError); ok { + code = he.Code + message = fmt.Sprint(he.Message) + } else { + c.Logger().Error(err) + } + + ret := problem.NewDefault(code) + if message != "" { + ret.Detail = message + } + c.JSON(code, ret) +} + +type Validator struct { + validator *validator.Validate +} + +func (v *Validator) Validate(i interface{}) error { + if err := v.validator.Struct(i); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return nil +} + +func OpenDatabase() (*pgxpool.Pool, error) { + ctx := context.Background() + + port, err := strconv.ParseUint(os.Getenv("POSTGRES_PORT"), 10, 16) + if err != nil { + return nil, errors.New("invalid postgres port specified") + } + + config, _ := pgxpool.ParseConfig("") + config.ConnConfig.Host = os.Getenv("POSTGRES_SERVER") + config.ConnConfig.Port = uint16(port) + config.ConnConfig.Database = os.Getenv("POSTGRES_DB") + config.ConnConfig.User = os.Getenv("POSTGRES_USER") + config.ConnConfig.Password = os.Getenv("POSTGRES_PASSWORD") + config.ConnConfig.TLSConfig = nil + config.ConnConfig.RuntimeParams = map[string]string{ + "application_name": "keibi", + } + schema := os.Getenv("POSTGRES_SCHEMA") + if schema == "" { + schema = "keibi" + } + if schema != "disabled" { + config.ConnConfig.RuntimeParams["search_path"] = schema + } + + db, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + fmt.Printf("Could not connect to database, check your env variables!") + return nil, err + } + + if schema != "disabled" { + _, err = db.Exec(ctx, fmt.Sprintf("create schema if not exists %s", schema)) + if err != nil { + return nil, err + } + } + + fmt.Println("Migrating database") + dbi := stdlib.OpenDBFromPool(db) + defer dbi.Close() + + driver, err := pgxd.WithInstance(dbi, &pgxd.Config{}) + if err != nil { + return nil, err + } + m, err := migrate.NewWithDatabaseInstance("file://sql/migrations", "postgres", driver) + if err != nil { + return nil, err + } + m.Up() + fmt.Println("Migrating finished") + + return db, nil +} + +type Handler struct { + db *dbc.Queries + config *Configuration +} + +// @title Keibi - Kyoo's auth +// @version 1.0 +// @description Auth system made for kyoo. + +// @contact.name Repository +// @contact.url https://github.com/zoriya/kyoo + +// @license.name GPL-3.0 +// @license.url https://www.gnu.org/licenses/gpl-3.0.en.html + +// @host kyoo.zoriya.dev +// @BasePath /auth + +// @securityDefinitions.apiKey Token +// @in header +// @name Authorization + +// @securityDefinitions.apiKey Jwt +// @in header +// @name Authorization +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Validator = &Validator{validator: validator.New(validator.WithRequiredStructEnabled())} + e.HTTPErrorHandler = ErrorHandler + + db, err := OpenDatabase() + if err != nil { + e.Logger.Fatal("Could not open databse: ", err) + return + } + + h := Handler{ + db: dbc.New(db), + } + conf, err := LoadConfiguration(h.db) + if err != nil { + e.Logger.Fatal("Could not load configuration: ", err) + return + } + h.config = conf + + r := e.Group("") + r.Use(echojwt.WithConfig(echojwt.Config{ + SigningMethod: "RS256", + SigningKey: h.config.JwtPublicKey, + })) + + r.GET("/users", h.ListUsers) + r.GET("/users/:id", h.GetUser) + r.GET("/users/me", h.GetMe) + r.DELETE("/users/:id", h.DeleteUser) + r.DELETE("/users/me", h.DeleteSelf) + e.POST("/users", h.Register) + + e.POST("/sessions", h.Login) + r.DELETE("/sessions", h.Logout) + r.DELETE("/sessions/:id", h.Logout) + + e.GET("/jwt", h.CreateJwt) + e.GET("/info", h.GetInfo) + + e.GET("/swagger/*", echoSwagger.WrapHandler) + + e.Logger.Fatal(e.Start(":4568")) +} diff --git a/auth/robot/auth.resource b/auth/robot/auth.resource new file mode 100644 index 00000000..51298261 --- /dev/null +++ b/auth/robot/auth.resource @@ -0,0 +1,43 @@ +*** Settings *** +Documentation Common things to handle rest requests + +Library REST http://localhost:8901/auth + + +*** 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 new file mode 100644 index 00000000..6f6b7f1b --- /dev/null +++ b/auth/robot/sessions.robot @@ -0,0 +1,36 @@ +*** 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 new file mode 100644 index 00000000..603fd1ec --- /dev/null +++ b/auth/robot/users.robot @@ -0,0 +1,33 @@ +*** 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 new file mode 100644 index 00000000..1092f5fa --- /dev/null +++ b/auth/sessions.go @@ -0,0 +1,157 @@ +package main + +import ( + "cmp" + "context" + "crypto/rand" + "encoding/base64" + "net/http" + "time" + + "github.com/alexedwards/argon2id" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/keibi/dbc" +) + +type Session struct { + // Unique id of this session. Can be used for calls to DELETE + Id uuid.UUID `json:"id"` + // When was the session first opened + CreatedDate time.Time `json:"createdDate"` + // Last date this session was used to access a service. + LastUsed time.Time `json:"lastUsed"` + // Device that created the session. + Device *string `json:"device"` +} + +func MapSession(ses *dbc.Session) Session { + return Session{ + Id: ses.Id, + CreatedDate: ses.CreatedDate, + LastUsed: ses.LastUsed, + Device: ses.Device, + } +} + +type LoginDto struct { + // Either the email or the username. + Login string `json:"login" validate:"required"` + // Password of the account. + Password string `json:"password" validate:"required"` +} + +// @Summary Login +// @Description Login to your account and open a session +// @Tags sessions +// @Accept json +// @Produce json +// @Param device query string false "The device the created session will be used on" +// @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)" +// @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()) + } + if err = c.Validate(&req); err != nil { + return err + } + + dbuser, err := h.db.GetUserByLogin(context.Background(), req.Login) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "No account exists with the specified email or username.") + } + if dbuser.Password == nil { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Can't login with password, this account was created with OIDC.") + } + + match, err := argon2id.ComparePasswordAndHash(req.Password, *dbuser.Password) + if err != nil { + return err + } + if !match { + return echo.NewHTTPError(http.StatusForbidden, "Invalid password") + } + + user := MapDbUser(&dbuser) + return h.createSession(c, &user) +} + +func (h *Handler) createSession(c echo.Context, user *User) error { + ctx := context.Background() + + id := make([]byte, 64) + _, err := rand.Read(id) + if err != nil { + return err + } + + dev := cmp.Or(c.Param("device"), c.Request().Header.Get("User-Agent")) + device := &dev + if dev == "" { + device = nil + } + + session, err := h.db.CreateSession(ctx, dbc.CreateSessionParams{ + Token: base64.StdEncoding.EncodeToString(id), + UserPk: user.Pk, + Device: device, + }) + if err != nil { + return err + } + return c.JSON(201, session) +} + +// @Summary Logout +// @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) +// @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] +// @Router /sessions/current [delete] +func (h *Handler) Logout(c echo.Context) error { + uid, err := GetCurrentUserId(c) + if err != nil { + return err + } + + session := c.Param("id") + if session == "current" { + sid, ok := c.Get("user").(*jwt.Token).Claims.(jwt.MapClaims)["sid"] + if !ok { + return echo.NewHTTPError(400, "Missing session id") + } + session = sid.(string) + } + sid, err := uuid.Parse(session) + if err != nil { + return echo.NewHTTPError(400, "Invalid session id") + } + + ret, err := h.db.DeleteSession(context.Background(), dbc.DeleteSessionParams{ + Id: sid, + UserId: uid, + }) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(404, "Session not found with specified id") + } else if err != nil { + return err + } + return c.JSON(200, MapSession(&ret)) +} diff --git a/auth/sql/migrations/000001_users.down.sql b/auth/sql/migrations/000001_users.down.sql new file mode 100644 index 00000000..d70106fc --- /dev/null +++ b/auth/sql/migrations/000001_users.down.sql @@ -0,0 +1,6 @@ +begin; + +drop table oidc_handle; +drop table users; + +commit; diff --git a/auth/sql/migrations/000001_users.up.sql b/auth/sql/migrations/000001_users.up.sql new file mode 100644 index 00000000..7076151d --- /dev/null +++ b/auth/sql/migrations/000001_users.up.sql @@ -0,0 +1,30 @@ +begin; + +create table users( + pk serial primary key, + id uuid not null default gen_random_uuid(), + username varchar(256) not null unique, + email varchar(320) not null unique, + password text, + claims jsonb not null, + + created_date timestamptz not null default now()::timestamptz, + last_seen timestamptz not null default now()::timestamptz +); + +create table oidc_handle( + user_pk integer not null references users(pk) on delete cascade, + provider varchar(256) not null, + + id text not null, + username varchar(256) not null, + profile_url text, + + access_token text, + refresh_token text, + expire_at timestamptz, + + constraint oidc_handle_pk primary key (user_pk, provider) +); + +commit; diff --git a/auth/sql/migrations/000002_config.down.sql b/auth/sql/migrations/000002_config.down.sql new file mode 100644 index 00000000..2fd15f61 --- /dev/null +++ b/auth/sql/migrations/000002_config.down.sql @@ -0,0 +1,5 @@ +begin; + +drop table config; + +commit; diff --git a/auth/sql/migrations/000002_config.up.sql b/auth/sql/migrations/000002_config.up.sql new file mode 100644 index 00000000..7d9e4b72 --- /dev/null +++ b/auth/sql/migrations/000002_config.up.sql @@ -0,0 +1,8 @@ +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/000003_sessions.down.sql new file mode 100644 index 00000000..7a17cccd --- /dev/null +++ b/auth/sql/migrations/000003_sessions.down.sql @@ -0,0 +1,5 @@ +begin; + +drop table sessions; + +commit; diff --git a/auth/sql/migrations/000003_sessions.up.sql b/auth/sql/migrations/000003_sessions.up.sql new file mode 100644 index 00000000..61e11291 --- /dev/null +++ b/auth/sql/migrations/000003_sessions.up.sql @@ -0,0 +1,13 @@ +begin; + +create table sessions( + pk serial primary key, + id uuid not null default gen_random_uuid(), + token varchar(128) not null unique, + user_pk integer not null references users(pk) on delete cascade, + created_date timestamptz not null default now()::timestamptz, + last_used timestamptz not null default now()::timestamptz, + device varchar(1024) +); + +commit; diff --git a/auth/sql/queries/config.sql b/auth/sql/queries/config.sql new file mode 100644 index 00000000..3f3db9d5 --- /dev/null +++ b/auth/sql/queries/config.sql @@ -0,0 +1,21 @@ +-- 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/sessions.sql b/auth/sql/queries/sessions.sql new file mode 100644 index 00000000..82127d80 --- /dev/null +++ b/auth/sql/queries/sessions.sql @@ -0,0 +1,45 @@ +-- name: GetUserFromToken :one +select + s.id, + s.last_used, + sqlc.embed(u) +from + users as u + inner join sessions as s on u.pk = s.user_pk +where + s.token = $1 +limit 1; + +-- name: TouchSession :exec +update + sessions +set + last_used = now()::timestamptz +where + id = $1; + +-- name: GetUserSessions :many +select + s.* +from + sessions as s + inner join users as u on u.pk = s.user_pk +where + u.pk = $1 +order by + last_used; + +-- name: CreateSession :one +insert into sessions(token, user_pk, device) + values ($1, $2, $3) +returning + *; + +-- name: DeleteSession :one +delete from sessions as s using users as u +where s.user_pk = u.pk + and s.id = $1 + and u.id = sqlc.arg(user_id) +returning + s.*; + diff --git a/auth/sql/queries/users.sql b/auth/sql/queries/users.sql new file mode 100644 index 00000000..c57315ec --- /dev/null +++ b/auth/sql/queries/users.sql @@ -0,0 +1,76 @@ +-- name: GetAllUsers :many +select + * +from + users +order by + id +limit $1; + +-- name: GetAllUsersAfter :many +select + * +from + users +where + id >= sqlc.arg(after_id) +order by + id +limit $1; + +-- name: GetUser :many +select + sqlc.embed(u), + h.provider, + h.id, + h.username, + h.profile_url +from + users as u + left join oidc_handle as h on u.pk = h.user_pk +where + u.id = $1; + +-- name: GetUserByLogin :one +select + * +from + users +where + email = sqlc.arg(login) + or username = sqlc.arg(login) +limit 1; + +-- name: TouchUser :exec +update + users +set + last_used = now()::timestamptz +where + id = $1; + +-- name: CreateUser :one +insert into users(username, email, password, claims) + values ($1, $2, $3, $4) +returning + *; + +-- name: UpdateUser :one +update + users +set + username = $2, + email = $3, + password = $4, + claims = $5 +where + id = $1 +returning + *; + +-- name: DeleteUser :one +delete from users +where id = $1 +returning + *; + diff --git a/auth/sqlc.yaml b/auth/sqlc.yaml new file mode 100644 index 00000000..3bc35e05 --- /dev/null +++ b/auth/sqlc.yaml @@ -0,0 +1,36 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "sql/queries" + schema: "sql/migrations" + gen: + go: + package: "dbc" + sql_package: "pgx/v5" + out: "dbc" + emit_pointers_for_null_types: true + emit_json_tags: true + json_tags_case_style: camel + initialisms: [] + overrides: + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" + - db_type: "timestamptz" + nullable: true + go_type: + import: "time" + type: "Time" + pointer: true + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - column: "users.claims" + go_type: + import: "github.com/golang-jwt/jwt/v5" + package: "jwt" + type: "MapClaims" + + diff --git a/auth/users.go b/auth/users.go new file mode 100644 index 00000000..31ee799c --- /dev/null +++ b/auth/users.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "net/http" + "time" + + "github.com/alexedwards/argon2id" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/keibi/dbc" +) + +type User struct { + // Primary key in database + Pk int32 `json:"-"` + // Id of the user. + Id uuid.UUID `json:"id"` + // Username of the user. Can be used as a login. + Username string `json:"username"` + // Email of the user. Can be used as a login. + Email string `json:"email" format:"email"` + // When was this account created? + CreatedDate time.Time `json:"createdDate"` + // When was the last time this account made any authorized request? + LastSeen time.Time `json:"lastSeen"` + // List of custom claims JWT created via get /jwt will have + Claims jwt.MapClaims `json:"claims"` + // 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"` + // Username of the user on the external service. + Username string `json:"username"` + // Link to the profile of the user on the external service. Null if unknown or irrelevant. + ProfileUrl *string `json:"profileUrl" format:"url"` +} + +type RegisterDto struct { + // Username of the new account, can't contain @ signs. Can be used for login. + Username string `json:"username" validate:"required,excludes=@"` + // Valid email that could be used for forgotten password requests. Can be used for login. + Email string `json:"email" validate:"required,email" format:"email"` + // Password to use. + Password string `json:"password" validate:"required"` +} + +func MapDbUser(user *dbc.User) User { + return User{ + Pk: user.Pk, + Id: user.Id, + Username: user.Username, + Email: user.Email, + CreatedDate: user.CreatedDate, + LastSeen: user.LastSeen, + Claims: user.Claims, + Oidc: nil, + } +} + +func MapOidc(oidc *dbc.GetUserRow) OidcHandle { + return OidcHandle{ + Id: *oidc.Id, + Username: *oidc.Username, + ProfileUrl: oidc.ProfileUrl, + } +} + +// @Summary List all users +// @Description List all users existing in this instance. +// @Tags users +// @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" +// @Router /users [get] +func (h *Handler) ListUsers(c echo.Context) error { + err := CheckPermissions(c, []string{"user.read"}) + if err != nil { + return err + } + + ctx := context.Background() + limit := int32(20) + id := c.Param("afterId") + + var users []dbc.User + if id == "" { + users, err = h.db.GetAllUsers(ctx, limit) + } else { + uid, uerr := uuid.Parse(id) + if uerr != nil { + return echo.NewHTTPError(400, "Invalid `afterId` parameter, uuid was expected") + } + users, err = h.db.GetAllUsersAfter(ctx, dbc.GetAllUsersAfterParams{ + Limit: limit, + AfterId: uid, + }) + } + + if err != nil { + return err + } + + var ret []User + for _, user := range users { + ret = append(ret, MapDbUser(&user)) + } + // TODO: switch to a Page + return c.JSON(200, ret) +} + +// @Summary Get user +// @Description Get informations about a user from it's id +// @Tags users +// @Produce json +// @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" +// @Router /users/{id} [get] +func (h *Handler) GetUser(c echo.Context) error { + err := CheckPermissions(c, []string{"user.read"}) + if err != nil { + return err + } + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(400, "Invalid id") + } + dbuser, err := h.db.GetUser(context.Background(), id) + if err != nil { + return err + } + + user := MapDbUser(&dbuser[0].User) + for _, oidc := range dbuser { + if oidc.Provider != nil { + user.Oidc[*oidc.Provider] = MapOidc(&oidc) + } + } + + return c.JSON(200, user) +} + +// @Summary Get me +// @Description Get informations about the currently connected user +// @Tags users +// @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)" +// @Router /users/me [get] +func (h *Handler) GetMe(c echo.Context) error { + id, err := GetCurrentUserId(c) + if err != nil { + return err + } + dbuser, err := h.db.GetUser(context.Background(), id) + if err != nil { + return err + } + + user := MapDbUser(&dbuser[0].User) + for _, oidc := range dbuser { + if oidc.Provider != nil { + user.Oidc[*oidc.Provider] = MapOidc(&oidc) + } + } + + return c.JSON(200, user) +} + +// @Summary Register +// @Description Register as a new user and open a session for it +// @Tags users +// @Accept json +// @Produce json +// @Param device query string false "The device the created session will be used on" +// @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" +// @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()) + } + if err = c.Validate(&req); err != nil { + return err + } + + pass, err := argon2id.CreateHash(req.Password, argon2id.DefaultParams) + if err != nil { + return err + } + + duser, err := h.db.CreateUser(context.Background(), dbc.CreateUserParams{ + Username: req.Username, + Email: req.Email, + Password: &pass, + Claims: h.config.DefaultClaims, + }) + if ErrIs(err, pgerrcode.UniqueViolation) { + return echo.NewHTTPError(409, "Email or username already taken") + } else if err != nil { + return err + } + user := MapDbUser(&duser) + return h.createSession(c, &user) +} + +// @Summary Delete user +// @Description Delete an account and all it's sessions. +// @Tags users +// @Accept json +// @Produce json +// @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" +// @Router /users/{id} [delete] +func (h *Handler) DeleteUser(c echo.Context) error { + uid, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(400, "Invalid id given: not an uuid") + } + + ret, err := h.db.DeleteUser(context.Background(), uid) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(404, "No user found with given id") + } else if err != nil { + return err + } + return c.JSON(200, MapDbUser(&ret)) +} + +// @Summary Delete self +// @Description Delete your account and all your sessions +// @Tags users +// @Accept json +// @Produce json +// @Security Jwt +// @Success 200 {object} User +// @Router /users/me [delete] +func (h *Handler) DeleteSelf(c echo.Context) error { + uid, err := GetCurrentUserId(c) + if err != nil { + return err + } + + ret, err := h.db.DeleteUser(context.Background(), uid) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(403, "Invalid token, user already deleted.") + } else if err != nil { + return err + } + return c.JSON(200, MapDbUser(&ret)) +} diff --git a/auth/utils.go b/auth/utils.go new file mode 100644 index 00000000..38753bc3 --- /dev/null +++ b/auth/utils.go @@ -0,0 +1,73 @@ +package main + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/labstack/echo/v4" +) + +func GetCurrentUserId(c echo.Context) (uuid.UUID, error) { + user := c.Get("user").(*jwt.Token) + if user == nil { + return uuid.UUID{}, echo.NewHTTPError(401, "Unauthorized") + } + sub, err := user.Claims.GetSubject() + if err != nil { + return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrive subject") + } + ret, err := uuid.Parse(sub) + 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 { + return echo.NewHTTPError(401, "Not logged in") + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return echo.NewHTTPError(403, "Could not retrieve claims") + } + + permissions_claims, ok := claims["permissions"] + if !ok { + return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", ")) + } + permissions, ok := permissions_claims.([]string) + if !ok { + return echo.NewHTTPError(403, "Invalid permission claim.") + } + + missing := make([]string, 0) + for _, perm := range perms { + if !slices.Contains(permissions, perm) { + missing = append(missing, perm) + } + } + + if len(missing) != 0 { + return echo.NewHTTPError( + 403, + fmt.Sprintf("Missing permissions: %s.", strings.Join(missing, ", ")), + ) + } + return nil +} + +func ErrIs(err error, code string) bool { + var pgerr *pgconn.PgError + + if !errors.As(err, &pgerr) { + return false + } + return pgerr.Code == code +} diff --git a/back/tests/robot/.gitignore b/back/tests/.gitignore similarity index 100% rename from back/tests/robot/.gitignore rename to back/tests/.gitignore diff --git a/back/tests/robot/auth/auth.robot b/back/tests/auth/auth.robot similarity index 99% rename from back/tests/robot/auth/auth.robot rename to back/tests/auth/auth.robot index 6b361ddd..02b7617b 100644 --- a/back/tests/robot/auth/auth.robot +++ b/back/tests/auth/auth.robot @@ -80,4 +80,3 @@ Login Should Be Equal As Strings ${res["body"]} ${me["body"]} [Teardown] DELETE /auth/me - diff --git a/back/tests/robot/pyproject.toml b/back/tests/pyproject.toml similarity index 100% rename from back/tests/robot/pyproject.toml rename to back/tests/pyproject.toml diff --git a/back/tests/robot/rest.resource b/back/tests/rest.resource similarity index 100% rename from back/tests/robot/rest.resource rename to back/tests/rest.resource diff --git a/docker-compose.build.yml b/docker-compose.build.yml index a883166c..a5114db5 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -60,6 +60,23 @@ services: - "traefik.enable=true" - "traefik.http.routers.front.rule=PathPrefix(`/`)" + auth: + build: ./auth + restart: on-failure + depends_on: + postgres: + condition: service_healthy + env_file: + - ./.env + labels: + - "traefik.enable=true" + - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`)" + - "traefik.http.routers.auth.middlewares=auth-sp" + - "traefik.http.middlewares.auth-sp.stripprefix.prefixes=/auth" + - "traefik.http.middlewares.auth-sp.stripprefix.forceSlash=false" + profiles: + - "v5" + scanner: build: ./scanner restart: on-failure diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cca0f2e0..6c07f455 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -84,6 +84,27 @@ services: - "traefik.enable=true" - "traefik.http.routers.front.rule=PathPrefix(`/`)" + auth: + build: + context: ./auth + dockerfile: Dockerfile.dev + restart: on-failure + depends_on: + postgres: + condition: service_healthy + ports: + - "4568:4568" + env_file: + - ./.env + volumes: + - ./auth:/app + labels: + - "traefik.enable=true" + - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`)" + - "traefik.http.routers.auth.middlewares=auth-sp" + - "traefik.http.middlewares.auth-sp.stripprefix.prefixes=/auth" + - "traefik.http.middlewares.auth-sp.stripprefix.forceSlash=false" + scanner: build: ./scanner restart: on-failure diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4ba4057b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[tool.robotidy] +diff = false +overwrite = true +verbose = false +separator = "space" +spacecount = 2 +line_length = 120 +lineseparator = "native" +skip_gitignore = true +ignore_git_dir = true +configure = [ + "AddMissingEnd:enabled=True", + "NormalizeSeparators:enabled=True", + "DiscardEmptySections:enabled=True", + "MergeAndOrderSections:enabled=True", + "RemoveEmptySettings:enabled=True", + "ReplaceEmptyValues:enabled=True", + "ReplaceWithVAR:enabled=False", + "NormalizeAssignments:enabled=True", + "GenerateDocumentation:enabled=False", + "OrderSettings:enabled=True", + "OrderSettingsSection:enabled=True", + "NormalizeTags:enabled=True", + "OrderTags:enabled=False", + "RenameVariables:enabled=False", + "IndentNestedKeywords:enabled=False", + "AlignSettingsSection:enabled=True", + "AlignVariablesSection:enabled=True", + "AlignTemplatedTestCases:enabled=False", + "AlignTestCasesSection:enabled=False", + "AlignKeywordsSection:enabled=False", + "NormalizeNewLines:enabled=True", + "NormalizeSectionHeaderName:enabled=True", + "NormalizeSettingName:enabled=True", + "ReplaceRunKeywordIf:enabled=True", + "SplitTooLongLine:enabled=True", + "SmartSortKeywords:enabled=False", + "RenameTestCases:enabled=False", + "RenameKeywords:enabled=False", + "ReplaceReturns:enabled=True", + "ReplaceBreakContinue:enabled=True", + "InlineIf:enabled=True", + "Translate:enabled=False", + "NormalizeComments:enabled=True", +] diff --git a/back/tests/robot/requirements.txt b/requirements.txt similarity index 100% rename from back/tests/robot/requirements.txt rename to requirements.txt diff --git a/shell.nix b/shell.nix index 0e91c3ba..51dcdd54 100644 --- a/shell.nix +++ b/shell.nix @@ -11,6 +11,9 @@ dataclasses-json msgspec langcodes + + # robotframework + # restinstance needs to be packaged ]); dotnet = with pkgs.dotnetCorePackages; combinePackages [ @@ -38,6 +41,9 @@ in biome kubernetes-helm go-migrate + sqlc + go-swag + robotframework-tidy ]; DOTNET_ROOT = "${dotnet}";