mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-31 10:37:13 -04:00 
			
		
		
		
	Add initial auth module (for v5) (#610)
This commit is contained in:
		
						commit
						c3b4f64941
					
				| @ -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 | ||||
|  | ||||
							
								
								
									
										20
									
								
								.github/workflows/robot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/robot.yml
									
									
									
									
										vendored
									
									
								
							| @ -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
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -8,3 +8,4 @@ output.xml | ||||
| report.html | ||||
| chart/charts | ||||
| chart/Chart.lock | ||||
| tmp | ||||
|  | ||||
							
								
								
									
										12
									
								
								auth/.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								auth/.dockerignore
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										13
									
								
								auth/.env.example
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										4
									
								
								auth/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| # generated via sqlc | ||||
| dbc/ | ||||
| # genereated via swag | ||||
| docs/ | ||||
							
								
								
									
										2
									
								
								auth/.swaggo
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								auth/.swaggo
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| replace jwt.MapClaims map[string]string | ||||
| replace uuid.UUID string | ||||
							
								
								
									
										25
									
								
								auth/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								auth/Dockerfile
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										16
									
								
								auth/Dockerfile.dev
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										151
									
								
								auth/README.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										79
									
								
								auth/config.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										62
									
								
								auth/go.mod
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										228
									
								
								auth/go.sum
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										92
									
								
								auth/jwt.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										183
									
								
								auth/main.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										43
									
								
								auth/robot/auth.resource
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										36
									
								
								auth/robot/sessions.robot
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										33
									
								
								auth/robot/users.robot
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										157
									
								
								auth/sessions.go
									
									
									
									
									
										Normal 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)) | ||||
| } | ||||
							
								
								
									
										6
									
								
								auth/sql/migrations/000001_users.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								auth/sql/migrations/000001_users.down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| begin; | ||||
| 
 | ||||
| drop table oidc_handle; | ||||
| drop table users; | ||||
| 
 | ||||
| commit; | ||||
							
								
								
									
										30
									
								
								auth/sql/migrations/000001_users.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								auth/sql/migrations/000001_users.up.sql
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										5
									
								
								auth/sql/migrations/000002_config.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								auth/sql/migrations/000002_config.down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| begin; | ||||
| 
 | ||||
| drop table config; | ||||
| 
 | ||||
| commit; | ||||
							
								
								
									
										8
									
								
								auth/sql/migrations/000002_config.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								auth/sql/migrations/000002_config.up.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| begin; | ||||
| 
 | ||||
| create table config( | ||||
| 	key varchar(256) not null primary key, | ||||
| 	value text not null | ||||
| ); | ||||
| 
 | ||||
| commit; | ||||
							
								
								
									
										5
									
								
								auth/sql/migrations/000003_sessions.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								auth/sql/migrations/000003_sessions.down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| begin; | ||||
| 
 | ||||
| drop table sessions; | ||||
| 
 | ||||
| commit; | ||||
							
								
								
									
										13
									
								
								auth/sql/migrations/000003_sessions.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								auth/sql/migrations/000003_sessions.up.sql
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										21
									
								
								auth/sql/queries/config.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								auth/sql/queries/config.sql
									
									
									
									
									
										Normal 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 | ||||
| 	*; | ||||
| 
 | ||||
							
								
								
									
										45
									
								
								auth/sql/queries/sessions.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								auth/sql/queries/sessions.sql
									
									
									
									
									
										Normal 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.*; | ||||
| 
 | ||||
							
								
								
									
										76
									
								
								auth/sql/queries/users.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								auth/sql/queries/users.sql
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										36
									
								
								auth/sqlc.yaml
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										272
									
								
								auth/users.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										73
									
								
								auth/utils.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @ -80,4 +80,3 @@ Login | ||||
|     Should Be Equal As Strings    ${res["body"]}    ${me["body"]} | ||||
| 
 | ||||
|     [Teardown]    DELETE    /auth/me | ||||
| 
 | ||||
| @ -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 | ||||
|  | ||||
| @ -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
									
								
							
							
						
						
									
										45
									
								
								pyproject.toml
									
									
									
									
									
										Normal 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", | ||||
| ] | ||||
| @ -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}"; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user