Add initial auth module (for v5) (#610)

This commit is contained in:
Zoe Roux 2024-10-19 19:14:27 +02:00 committed by GitHub
commit c3b4f64941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1829 additions and 12 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ output.xml
report.html
chart/charts
chart/Chart.lock
tmp

12
auth/.dockerignore Normal file
View File

@ -0,0 +1,12 @@
Dockerfile*
*.md
.dockerignore
.gitignore
.env*
# generated via sqlc
dbc/
# genereated via swag
docs/
# vim: ft=gitignore

13
auth/.env.example Normal file
View File

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

4
auth/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# generated via sqlc
dbc/
# genereated via swag
docs/

2
auth/.swaggo Normal file
View File

@ -0,0 +1,2 @@
replace jwt.MapClaims map[string]string
replace uuid.UUID string

25
auth/Dockerfile Normal file
View File

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

16
auth/Dockerfile.dev Normal file
View File

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

151
auth/README.md Normal file
View File

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

79
auth/config.go Normal file
View File

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

62
auth/go.mod Normal file
View File

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

228
auth/go.sum Normal file
View File

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

92
auth/jwt.go Normal file
View File

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

183
auth/main.go Normal file
View File

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

43
auth/robot/auth.resource Normal file
View File

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

36
auth/robot/sessions.robot Normal file
View File

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

33
auth/robot/users.robot Normal file
View File

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

157
auth/sessions.go Normal file
View File

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

View File

@ -0,0 +1,6 @@
begin;
drop table oidc_handle;
drop table users;
commit;

View File

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

View File

@ -0,0 +1,5 @@
begin;
drop table config;
commit;

View File

@ -0,0 +1,8 @@
begin;
create table config(
key varchar(256) not null primary key,
value text not null
);
commit;

View File

@ -0,0 +1,5 @@
begin;
drop table sessions;
commit;

View File

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

View File

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

View File

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

View File

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

36
auth/sqlc.yaml Normal file
View File

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

272
auth/users.go Normal file
View File

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

73
auth/utils.go Normal file
View File

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

View File

@ -80,4 +80,3 @@ Login
Should Be Equal As Strings ${res["body"]} ${me["body"]}
[Teardown] DELETE /auth/me

View File

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

View File

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

45
pyproject.toml Normal file
View File

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

View File

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