Initial QSfera import
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# maintain v2 separate mocks dir
|
||||
dir: "{{.InterfaceDir}}/mocks"
|
||||
structname: "{{.InterfaceName}}"
|
||||
filename: "{{.InterfaceName | snakecase }}.go"
|
||||
pkgname: mocks
|
||||
|
||||
template: testify
|
||||
packages:
|
||||
github.com/qsfera/server/services/proxy/pkg/user/backend:
|
||||
interfaces:
|
||||
UserBackend: {}
|
||||
github.com/qsfera/server/services/proxy/pkg/userroles:
|
||||
interfaces:
|
||||
UserRoleAssigner: {}
|
||||
go-micro.dev/v4/store:
|
||||
config:
|
||||
dir: pkg/staticroutes/internal/backchannellogout/mocks
|
||||
interfaces:
|
||||
Store: {}
|
||||
@@ -0,0 +1,15 @@
|
||||
SHELL := bash
|
||||
NAME := proxy
|
||||
|
||||
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
|
||||
include ../../.bingo/Variables.mk
|
||||
endif
|
||||
|
||||
include ../../.make/default.mk
|
||||
include ../../.make/go.mk
|
||||
include ../../.make/release.mk
|
||||
include ../../.make/docs.mk
|
||||
|
||||
.PHONY: go-generate
|
||||
go-generate: $(MOCKERY)
|
||||
$(MOCKERY)
|
||||
@@ -0,0 +1,320 @@
|
||||
# Proxy
|
||||
|
||||
The proxy service is an API-Gateway for the КуСфера microservices. Every HTTP request goes through this service. Authentication, logging and other preprocessing of requests also happens here. Mechanisms like request rate limiting or intrusion prevention are **not** included in the proxy service and must be setup in front like with an external reverse proxy.
|
||||
|
||||
The proxy service is the only service communicating to the outside and needs therefore usual protections against DDOS, Slow Loris or other attack vectors. All other services are not exposed to the outside, but also need protective measures when it comes to distributed setups like when using container orchestration over various physical servers.
|
||||
|
||||
## Authentication
|
||||
|
||||
The following request authentication schemes are implemented:
|
||||
|
||||
- Basic Auth (Only use in development, **never in production** setups!)
|
||||
- OpenID Connect
|
||||
- Signed URL
|
||||
- Public Share Token
|
||||
|
||||
## Configuring Routes
|
||||
|
||||
The proxy handles routing to all endpoints that КуСфера offers. The currently availabe default routes can be found [in the code](https://github.com/qsfera/server/blob/main/services/proxy/pkg/config/defaults/defaultconfig.go). Changing or adding routes can be necessary when writing own КуСфера extensions.
|
||||
|
||||
Due to the complexity when defining routes, these can only be defined in the yaml file but not via environment variables.
|
||||
|
||||
For _overwriting_ default routes, use the following yaml example:
|
||||
|
||||
```yaml
|
||||
policies:
|
||||
- name: qsfera
|
||||
routes:
|
||||
- endpoint: /
|
||||
service: qsfera.web.web
|
||||
- endpoint: /dav/
|
||||
service: qsfera.web.frontend
|
||||
```
|
||||
|
||||
For adding _additional_ routes to the default routes use:
|
||||
|
||||
```yaml
|
||||
additional_policies:
|
||||
- name: qsfera
|
||||
routes:
|
||||
- endpoint: /custom/endpoint
|
||||
service: qsfera.custom.custom
|
||||
```
|
||||
|
||||
A route has the following configurable parameters:
|
||||
|
||||
```yaml
|
||||
endpoint: "" # the url that should be routed
|
||||
service: "" # the service the url should be routed to
|
||||
unprotected: false # with false (default), calling the endpoint requires authorization.
|
||||
# with true, anyone can call the endpoint without authorisation.
|
||||
```
|
||||
|
||||
## Automatic User and Group Provisioning
|
||||
|
||||
When using an external OpenID Connect IDP, the proxy can be configured to automatically provision
|
||||
users upon their first login.
|
||||
|
||||
### Prequisites
|
||||
|
||||
A number of prerequisites must be met for automatic user provisioning to work:
|
||||
|
||||
* КуСфера must be configured to use an external OpenID Connect IDP
|
||||
* The `graph` service must be configured to allow updating users and groups
|
||||
(`GRAPH_LDAP_SERVER_WRITE_ENABLED`).
|
||||
* One of the claim values returned by the IDP as part of the userinfo response
|
||||
or the access token must be unique and stable for the user. I.e. the value
|
||||
must not change for the whole lifetime of the user. This claim is configured
|
||||
via the `PROXY_USER_OIDC_CLAIM` environment variable (see below). A natural
|
||||
choice would e.g. be the `sub` claim which is guaranteed to be unique and
|
||||
stable per IDP. If a claim like `email` or `preferred_username` is used, you
|
||||
have to ensure that the user's email address or username never changes.
|
||||
|
||||
### Configuration
|
||||
|
||||
To enable automatic user provisioning, the following environment variables must
|
||||
be set for the proxy service:
|
||||
|
||||
* `PROXY_AUTOPROVISION_ACCOUNTS`\
|
||||
Set to `true` to enable automatic user provisioning.
|
||||
* `PROXY_AUTOPROVISION_CLAIM_USERNAME`\
|
||||
The name of an OIDC claim whose value should be used as the username for the
|
||||
autoprovsioned user in КуСфера. Defaults to `preferred_username`.
|
||||
Can also be set to e.g. `sub` to guarantee a unique and stable username.
|
||||
* `PROXY_AUTOPROVISION_CLAIM_EMAIL`\
|
||||
The name of an OIDC claim whose value should be used for the `mail` attribute
|
||||
of the autoprovisioned user in КуСфера. Defaults to `email`.
|
||||
* `PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME`\
|
||||
The name of an OIDC claim whose value should be used for the `displayname`
|
||||
attribute of the autoprovisioned user in КуСфера. Defaults to `name`.
|
||||
* `PROXY_AUTOPROVISION_CLAIM_GROUPS`\
|
||||
The name of an OIDC claim whose value should be used to maintain a user's group
|
||||
membership. The claim value should contain a list of group names the user should
|
||||
be a member of. Defaults to `groups`.
|
||||
* `PROXY_USER_OIDC_CLAIM`\
|
||||
When resolving and authenticated OIDC user, the value of this claims is used to
|
||||
lookup the user in the users service. For auto provisioning setups this usually is the
|
||||
same claims as set via `PROXY_AUTOPROVISION_CLAIM_USERNAME`.
|
||||
* `PROXY_USER_CS3_CLAIM`\
|
||||
This is the name of the user attribute in КуСфера that is used to lookup the user by the
|
||||
value of the `PROXY_USER_OIDC_CLAIM`. For auto provisioning setups this usually
|
||||
needs to be set to `username`.
|
||||
|
||||
### How it Works
|
||||
|
||||
When a user logs into КуСфера for the first time, the proxy
|
||||
checks if that user already exists. This is done by querying the `users` service for users,
|
||||
where the attribute set in `PROXY_USER_CS3_CLAIM` matches the value of the OIDC
|
||||
claim configured in `PROXY_USER_OIDC_CLAIM`.
|
||||
|
||||
If the users does not exist, the proxy will create a new user via the `graph`
|
||||
service using the claim values configured in
|
||||
`PROXY_AUTOPROVISION_CLAIM_USERNAME`, `PROXY_AUTOPROVISION_CLAIM_EMAIL` and
|
||||
`PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME`.
|
||||
|
||||
If the user does already exist, the proxy checks if the displayname has changed
|
||||
and updates that accordingly via `graph` service.
|
||||
|
||||
Unless the claim configured via `PROXY_AUTOPROVISION_CLAIM_EMAIL` is the same
|
||||
as the one set via `PROXY_USER_OIDC_CLAIM` the proxy will also check if the
|
||||
email address has changed and update that as well.
|
||||
|
||||
Next, the proxy will check if the user is a member of the groups configured in
|
||||
`PROXY_AUTOPROVISION_CLAIM_GROUPS`. It will add the user to the groups listed
|
||||
via the OIDC claim that holds the groups defined in the envvar and removes it from
|
||||
all other groups that he is currently a member of.
|
||||
Groups that do not exist in the external IDP yet will be created. Note: This can be a
|
||||
somewhat costly operation, especially if the user is a member of a large number of
|
||||
groups. If the group memberships of a user are changed in the IDP after the
|
||||
first login, it can take up to 5 minutes until the changes are reflected in КуСфера.
|
||||
|
||||
### Claim Updates
|
||||
|
||||
OpenID Connect (OIDC) scopes are used by an application during authentication to authorize access to a user's detail, like name, email or picture information. A scope can also contain among other things groups, roles, and permissions data. Each scope returns a set of attributes, which are called claims. The scopes an application requests, depends on which attributes the application needs. Once the user authorizes the requested scopes, the claims are returned in a token.
|
||||
|
||||
These issued JWT tokens are immutable and integrity-protected. Which means, any change in the source requires issuing a new token containing updated claims. On the other hand side, there is no active synchronisation process between the identity provider (IDP) who issues the token and КуСфера. The earliest possible time that КуСфера will notice changes is, when the current access token has expired and a new access token is issued by the IDP, or the user logs out and relogs in.
|
||||
|
||||
**NOTES**
|
||||
|
||||
* For resource optimisation, КуСфера skips any checks and updates on groupmemberships, if the last update happened less than 5min ago.
|
||||
|
||||
* КуСфера can't differentiate between a group being renamed in the IDP and users being reassigned to a different group.
|
||||
|
||||
* КуСфера does not get aware when a group is being deleted in the IDP, a new claim will not hold any information from the deleted group. КуСфера does not track a claim history to compare.
|
||||
|
||||
#### Impacts
|
||||
|
||||
For shares or space memberships based on groups, a renamed or deleted group will impact accessing the resource:
|
||||
|
||||
* There is no user notification about the inability accessing the resource.
|
||||
* The user will only experience rejected access.
|
||||
* This also applies for connected apps like the Desktop, iOS or Android app!
|
||||
|
||||
To give access for rejected users on a resource, one with rights to share must update the group information.
|
||||
|
||||
## Automatic Quota Assignments
|
||||
|
||||
It is possible to automatically assign a specific quota to new users depending on their role.
|
||||
To do this, you need to configure a mapping between roles defined by their ID and the quota in bytes.
|
||||
The assignment can only be done via a `yaml` configuration and not via environment variables.
|
||||
See the following `proxy.yaml` config snippet for a configuration example.
|
||||
|
||||
```yaml
|
||||
role_quotas:
|
||||
<role ID1>: <quota1>
|
||||
<role ID2>: <quota2>
|
||||
```
|
||||
|
||||
## Automatic Role Assignments
|
||||
|
||||
When users login, they do automatically get a role assigned. The automatic role assignment can be
|
||||
configured in different ways. The `PROXY_ROLE_ASSIGNMENT_DRIVER` environment variable (or the `driver`
|
||||
setting in the `role_assignment` section of the configuration file select which mechanism to use for
|
||||
the automatic role assignment.
|
||||
|
||||
When set to `default`, all users which do not have a role assigned at the time for the first login will
|
||||
get the role 'user' assigned. (This is also the default behavior if `PROXY_ROLE_ASSIGNMENT_DRIVER`
|
||||
is unset.
|
||||
|
||||
When `PROXY_ROLE_ASSIGNMENT_DRIVER` is set to `oidc` the role assignment for a user will happen
|
||||
based on the values of an OpenID Connect Claim of that user. The name of the OpenID Connect Claim to
|
||||
be used for the role assignment can be configured via the `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`
|
||||
environment variable. It is also possible to define a mapping of claim values to role names defined
|
||||
in КуСфера via a `yaml` configuration. See the following `proxy.yaml` snippet for an example.
|
||||
|
||||
```yaml
|
||||
role_assignment:
|
||||
driver: oidc
|
||||
oidc_role_mapper:
|
||||
role_claim: qsferaRoles
|
||||
role_mapping:
|
||||
- role_name: admin
|
||||
claim_value: myAdminRole
|
||||
- role_name: spaceadmin
|
||||
claim_value: mySpaceAdminRole
|
||||
- role_name: user
|
||||
claim_value: myUserRole
|
||||
- role_name: guest
|
||||
claim_value: myGuestRole
|
||||
```
|
||||
|
||||
This would assign the role `admin` to users with the value `myAdminRole` in the claim `qsferaRoles`.
|
||||
The role `user` to users with the values `myUserRole` in the claims `qsferaRoles` and so on.
|
||||
|
||||
Claim values that are not mapped to a specific КуСфера role will be ignored.
|
||||
|
||||
Note: An КуСфера user can only have a single role assigned. If the configured
|
||||
`role_mapping` and a user's claim values result in multiple possible roles for a user, the order in
|
||||
which the role mappings are defined in the configuration is important. The first role in the
|
||||
`role_mappings` where the `claim_value` matches a value from the user's roles claim will be assigned
|
||||
to the user. So if e.g. a user's `qsferaRoles` claim has the values `myUserRole` and
|
||||
`mySpaceAdminRole` that user will get the КуСфера role `spaceadmin` assigned (because `spaceadmin`
|
||||
appears before `user` in the above sample configuration).
|
||||
|
||||
If a user's claim values don't match any of the configured role mappings an error will be logged and
|
||||
the user will not be able to login.
|
||||
|
||||
The default `role_claim` (or `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`) is `roles`. The default `role_mapping` is:
|
||||
|
||||
```yaml
|
||||
- role_name: admin
|
||||
claim_value: qsferaAdmin
|
||||
- role_name: spaceadmin
|
||||
claim_value: qsferaSpaceAdmin
|
||||
- role_name: user
|
||||
claim_value: qsferaUser
|
||||
- role_name: guest
|
||||
claim_value: qsferacloudGuest
|
||||
```
|
||||
|
||||
## Recommendations for Production Deployments
|
||||
|
||||
In a production deployment, you want to have basic authentication (`PROXY_ENABLE_BASIC_AUTH`) disabled which is the default state. You also want to setup a firewall to only allow requests to the proxy service or the reverse proxy if you have one. Requests to the other services should be blocked by the firewall.
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
For КуСфера, external resources like an IDP (e.g. Keycloak) or when using web office documents or web apps, require defining a CSP. If not defined, the referenced services will not work.
|
||||
|
||||
To create a Content Security Policy (CSP), you need to create a yaml file containing the CSP definitions. To activate the settings, reference the file as value in the `PROXY_CSP_CONFIG_FILE_LOCATION` environment variable. For each change, a restart of the КуСфера deployment or the proxy service is required.
|
||||
|
||||
A working example for a CSP can be found in a sub path of the `config` directory of the [qsfera-compose](https://github.com/qsfera/server-compose/tree/main/config) deployment example.
|
||||
|
||||
See the [Content Security Policy (CSP) Quick Reference Guide](https://content-security-policy.com) for a description of directives.
|
||||
|
||||
## Caching
|
||||
|
||||
The `proxy` service can use a configured store via `PROXY_OIDC_USERINFO_CACHE_STORE`. Possible stores are:
|
||||
- `memory`: Basic in-memory store and the default.
|
||||
- `redis-sentinel`: Stores data in a configured Redis Sentinel cluster.
|
||||
- `nats-js-kv`: Stores data using key-value-store feature of [nats jetstream](https://docs.nats.io/nats-concepts/jetstream/key-value-store)
|
||||
- `noop`: Stores nothing. Useful for testing. Not recommended in production environments.
|
||||
|
||||
Other store types may work but are not supported currently.
|
||||
|
||||
Note: The service can only be scaled if not using `memory` store and the stores are configured identically over all instances!
|
||||
|
||||
Note that if you have used one of the deprecated stores, you should reconfigure to one of the supported ones as the deprecated stores will be removed in a later version.
|
||||
|
||||
Store specific notes:
|
||||
- When using `redis-sentinel`, the Redis master to use is configured via e.g. `OC_CACHE_STORE_NODES` in the form of `<sentinel-host>:<sentinel-port>/<redis-master>` like `10.10.0.200:26379/mymaster`.
|
||||
- When using `nats-js-kv` it is recommended to set `OC_CACHE_STORE_NODES` to the same value as `OC_EVENTS_ENDPOINT`. That way the cache uses the same nats instance as the event bus.
|
||||
- When using the `nats-js-kv` store, it is possible to set `OC_CACHE_DISABLE_PERSISTENCE` to instruct nats to not persist cache data on disc.
|
||||
|
||||
|
||||
## Presigned Urls
|
||||
|
||||
To authenticate presigned URLs the proxy service needs to read signing keys from a store that is populated by the ocs service. Possible stores are:
|
||||
- `nats-js-kv`: Stores data using key-value-store feature of [nats jetstream](https://docs.nats.io/nats-concepts/jetstream/key-value-store)
|
||||
- `redis-sentinel`: Stores data in a configured Redis Sentinel cluster.
|
||||
- `qsferastoreservice`: Stores data in the legacy КуСфера store service. Requires setting `PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES` to `qsfera.api.store`.
|
||||
|
||||
The `memory` store cannot be used as it does not share the memory from the ocs service signing key memory store, even in a single process.
|
||||
|
||||
Make sure to configure the same store in the ocs service.
|
||||
|
||||
Store specific notes:
|
||||
- When using `redis-sentinel`, the Redis master to use is configured via e.g. `OC_CACHE_STORE_NODES` in the form of `<sentinel-host>:<sentinel-port>/<redis-master>` like `10.10.0.200:26379/mymaster`.
|
||||
- When using `nats-js-kv` it is recommended to set `OCS_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES` to the same value as `PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES`. That way the ocs uses the same nats instance as the proxy service.
|
||||
- When using the `nats-js-kv` store, it is possible to set `PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_DISABLE_PERSISTENCE` to instruct nats to not persist signing key data on disc.
|
||||
- When using `qsferastoreservice` the `PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES` must be set to the service name `qsfera.api.store`. It does not support TTL and stores the presigning keys indefinitely. Also, the store service needs to be started.
|
||||
|
||||
|
||||
## Special Settings
|
||||
|
||||
When using the КуСфера IDP service instead of an external IDP:
|
||||
|
||||
- Use the environment variable `OC_URL` to define how КуСфера can be accessed, mandatory use `https` as protocol for the URL.
|
||||
- If no reverse proxy is set up, the `PROXY_TLS` environment variable **must** be set to `true` because the embedded `libreConnect` shipped with the IDP service has a hard check if the connection is on TLS and uses the HTTPS protocol. If this mismatches, an error will be logged and no connection from the client can be established.
|
||||
- `PROXY_TLS` **can** be set to `false` if a reverse proxy is used and the https connection is terminated at the reverse proxy. When setting to `false`, the communication between the reverse proxy and КуСфера is not secured. If set to `true`, you must provide certificates.
|
||||
|
||||
## Metrics
|
||||
|
||||
The proxy service in КуСфера has the ability to expose metrics in the prometheus format. The metrics are exposed on the `/metrics` endpoint. There are two ways to run the КуСфера proxy service which has an impact on the number of metrics exposed.
|
||||
|
||||
### 1) Single Process Mode
|
||||
In the single process mode, all КуСфера services are running inside a single process. This is the default mode when using the `qsfera server` command to start the services. In this mode, the proxy service exposes metrics about the proxy service itself and about the КуСфера services it is proxying. This is due to the nature of the prometheus registry which is a singleton. The metrics exposed by the proxy service itself are prefixed with `qsfera_proxy_` and the metrics exposed by other qsfera services are prefixed with `qsfera_<service-name>_`.
|
||||
|
||||
### 2) Standalone Mode
|
||||
In this mode, the proxy service only exposes its own metrics. The metrics of the other КуСфера services are exposed on their own metrics endpoints.
|
||||
|
||||
### Available Metrics
|
||||
The following metrics are exposed by the proxy service:
|
||||
|
||||
| Metric Name | Description | Labels |
|
||||
|----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------|
|
||||
| `qsfera_proxy_requests_total` | [Counter](https://prometheus.io/docs/tutorials/understanding_metric_types/#counter) metric which reports the total number of HTTP requests. | `method`: HTTP method of the request |
|
||||
| `qsfera_proxy_errors_total` | [Counter](https://prometheus.io/docs/tutorials/understanding_metric_types/#counter) metric which reports the total number of HTTP requests which have failed. That counts all response codes >= 500 | `method`: HTTP method of the request |
|
||||
| `qsfera_proxy_duration_seconds` | [Histogram](https://prometheus.io/docs/tutorials/understanding_metric_types/#histogram) of the time (in seconds) each request took. A histogram metric uses buckets to count the number of events that fall into each bucket. | `method`: HTTP method of the request |
|
||||
| `qsfera_proxy_build_info{version}` | A metric with a constant `1` value labeled by version, exposing the version of the КуСфера proxy service. | `version`: Build version of the proxy |
|
||||
|
||||
### Prometheus Configuration
|
||||
The following is an example prometheus configuration for the single process mode. It assumes that the proxy debug address is configured to bind on all interfaces `PROXY_DEBUG_ADDR=0.0.0.0:9205` and that the proxy is available via the `qsfera` service name (typically in docker-compose). The prometheus service detects the `/metrics` endpoint automatically and scrapes it every 15 seconds.
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
scrape_configs:
|
||||
- job_name: qsfera_proxy
|
||||
static_configs:
|
||||
- targets: ["qsfera:9205"]
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config/parser"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "check health status",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnError(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the qsfera-proxy command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "proxy",
|
||||
Short: "proxy for КуСфера",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/justinas/alice"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/generators"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
pkgmiddleware "github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
"github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
policiessvc "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/proxy/pkg/metrics"
|
||||
"github.com/qsfera/server/services/proxy/pkg/middleware"
|
||||
"github.com/qsfera/server/services/proxy/pkg/proxy"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/qsfera/server/services/proxy/pkg/server/debug"
|
||||
proxyHTTP "github.com/qsfera/server/services/proxy/pkg/server/http"
|
||||
"github.com/qsfera/server/services/proxy/pkg/staticroutes"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/userroles"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/store"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/spf13/cobra"
|
||||
"go-micro.dev/v4/selector"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
userInfoCache := store.Create(
|
||||
store.Store(cfg.OIDC.UserinfoCache.Store),
|
||||
store.TTL(cfg.OIDC.UserinfoCache.TTL),
|
||||
microstore.Nodes(cfg.OIDC.UserinfoCache.Nodes...),
|
||||
microstore.Database(cfg.OIDC.UserinfoCache.Database),
|
||||
microstore.Table(cfg.OIDC.UserinfoCache.Table),
|
||||
store.DisablePersistence(cfg.OIDC.UserinfoCache.DisablePersistence),
|
||||
store.Authentication(cfg.OIDC.UserinfoCache.AuthUsername, cfg.OIDC.UserinfoCache.AuthPassword),
|
||||
)
|
||||
|
||||
signingKeyStore := store.Create(
|
||||
store.Store(cfg.PreSignedURL.SigningKeys.Store),
|
||||
store.TTL(cfg.PreSignedURL.SigningKeys.TTL),
|
||||
microstore.Nodes(cfg.PreSignedURL.SigningKeys.Nodes...),
|
||||
microstore.Database("proxy"),
|
||||
microstore.Table("signing-keys"),
|
||||
store.DisablePersistence(cfg.PreSignedURL.SigningKeys.DisablePersistence),
|
||||
store.Authentication(cfg.PreSignedURL.SigningKeys.AuthUsername, cfg.PreSignedURL.SigningKeys.AuthPassword),
|
||||
)
|
||||
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.GrpcClient, err = grpc.NewClient(
|
||||
append(
|
||||
grpc.GetClientOptions(cfg.GRPCClientTLS),
|
||||
grpc.WithTraceProvider(traceProvider))...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oidcHTTPClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
|
||||
oidcClient := oidc.NewOIDCClient(
|
||||
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
|
||||
oidc.WithLogger(logger),
|
||||
oidc.WithHTTPClient(oidcHTTPClient),
|
||||
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
|
||||
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
|
||||
)
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
m := metrics.New()
|
||||
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
rp, err := proxy.NewMultiHostReverseProxy(
|
||||
proxy.Logger(logger),
|
||||
proxy.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
cfg.Reva.Address,
|
||||
append(
|
||||
cfg.Reva.GetRevaOptions(),
|
||||
pool.WithRegistry(reg),
|
||||
pool.WithTracerProvider(traceProvider),
|
||||
)...)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to get gateway selector")
|
||||
}
|
||||
|
||||
serviceSelector := selector.NewSelector(selector.Registry(reg))
|
||||
|
||||
var userProvider backend.UserBackend
|
||||
switch cfg.AccountBackend {
|
||||
case "cs3":
|
||||
userProvider = backend.NewCS3UserBackend(
|
||||
backend.WithLogger(logger),
|
||||
backend.WithRevaGatewaySelector(gatewaySelector),
|
||||
backend.WithSelector(serviceSelector),
|
||||
backend.WithMachineAuthAPIKey(cfg.MachineAuthAPIKey),
|
||||
backend.WithOIDCissuer(cfg.OIDC.Issuer),
|
||||
backend.WithServiceAccount(cfg.ServiceAccount),
|
||||
backend.WithAutoProvisionClaims(cfg.AutoProvisionClaims),
|
||||
)
|
||||
default:
|
||||
logger.Fatal().Msgf("Invalid accounts backend type '%s'", cfg.AccountBackend)
|
||||
}
|
||||
|
||||
var publisher events.Stream
|
||||
if cfg.Events.Endpoint != "" {
|
||||
var err error
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
publisher, err = stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Events))
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing events publisher")
|
||||
return fmt.Errorf("could not initialize events publisher %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
lh := staticroutes.StaticRouteHandler{
|
||||
Prefix: cfg.HTTP.Root,
|
||||
UserInfoCache: userInfoCache,
|
||||
Logger: logger,
|
||||
Config: *cfg,
|
||||
OidcClient: oidcClient,
|
||||
OidcHttpClient: oidcHTTPClient,
|
||||
Proxy: rp,
|
||||
EventsPublisher: publisher,
|
||||
UserProvider: userProvider,
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
gr := runner.NewGroup()
|
||||
{
|
||||
middlewares := loadMiddlewares(logger, cfg, userInfoCache, signingKeyStore, traceProvider, *m, userProvider, publisher, gatewaySelector, serviceSelector)
|
||||
|
||||
server, err := proxyHTTP.Server(
|
||||
proxyHTTP.Handler(lh.Handler()),
|
||||
proxyHTTP.Logger(logger),
|
||||
proxyHTTP.Context(cfg.Context),
|
||||
proxyHTTP.Config(cfg),
|
||||
proxyHTTP.Metrics(metrics.New()),
|
||||
proxyHTTP.Middlewares(middlewares),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("server", "http").
|
||||
Msg("Failed to initialize server")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
|
||||
}
|
||||
|
||||
{
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(cfg.Context),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("server", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(cfg.Context)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadMiddlewares(logger log.Logger, cfg *config.Config,
|
||||
userInfoCache, signingKeyStore microstore.Store,
|
||||
traceProvider trace.TracerProvider, metrics metrics.Metrics,
|
||||
userProvider backend.UserBackend, publisher events.Publisher,
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient], serviceSelector selector.Selector) alice.Chain {
|
||||
|
||||
rolesClient := settingssvc.NewRoleService("qsfera.api.settings", cfg.GrpcClient)
|
||||
policiesProviderClient := policiessvc.NewPoliciesProviderService("qsfera.api.policies", cfg.GrpcClient)
|
||||
|
||||
var roleAssigner userroles.UserRoleAssigner
|
||||
switch cfg.RoleAssignment.Driver {
|
||||
case "default":
|
||||
roleAssigner = userroles.NewDefaultRoleAssigner(
|
||||
userroles.WithRoleService(rolesClient),
|
||||
userroles.WithLogger(logger),
|
||||
)
|
||||
case "oidc":
|
||||
roleAssigner = userroles.NewOIDCRoleAssigner(
|
||||
userroles.WithRoleService(rolesClient),
|
||||
userroles.WithLogger(logger),
|
||||
userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim),
|
||||
userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RolesMap),
|
||||
userroles.WithRevaGatewaySelector(gatewaySelector),
|
||||
userroles.WithServiceAccount(cfg.ServiceAccount),
|
||||
)
|
||||
default:
|
||||
logger.Fatal().Msgf("Invalid role assignment driver '%s'", cfg.RoleAssignment.Driver)
|
||||
}
|
||||
|
||||
oidcHTTPClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
|
||||
var authenticators []middleware.Authenticator
|
||||
if cfg.EnableBasicAuth {
|
||||
logger.Warn().Msg("basic auth enabled, use only for testing or development")
|
||||
authenticators = append(authenticators, middleware.BasicAuthenticator{
|
||||
Logger: logger,
|
||||
UserProvider: userProvider,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.AuthMiddleware.AllowAppAuth {
|
||||
authenticators = append(authenticators, middleware.AppAuthAuthenticator{
|
||||
Logger: logger,
|
||||
RevaGatewaySelector: gatewaySelector,
|
||||
UserRoleAssigner: roleAssigner,
|
||||
})
|
||||
}
|
||||
authenticators = append(authenticators, middleware.NewOIDCAuthenticator(
|
||||
middleware.Logger(logger),
|
||||
middleware.UserInfoCache(userInfoCache),
|
||||
middleware.DefaultAccessTokenTTL(cfg.OIDC.UserinfoCache.TTL),
|
||||
middleware.HTTPClient(oidcHTTPClient),
|
||||
middleware.OIDCIss(cfg.OIDC.Issuer),
|
||||
middleware.OIDCClient(oidc.NewOIDCClient(
|
||||
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
|
||||
oidc.WithLogger(logger),
|
||||
oidc.WithHTTPClient(oidcHTTPClient),
|
||||
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
|
||||
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
|
||||
)),
|
||||
middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo),
|
||||
))
|
||||
authenticators = append(authenticators, middleware.PublicShareAuthenticator{
|
||||
Logger: logger,
|
||||
RevaGatewaySelector: gatewaySelector,
|
||||
})
|
||||
|
||||
signURLVerifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(cfg.Commons.URLSigningSecret))
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to initialize signed URL configuration.")
|
||||
}
|
||||
|
||||
authenticators = append(authenticators, middleware.SignedURLAuthenticator{
|
||||
Logger: logger,
|
||||
PreSignedURLConfig: cfg.PreSignedURL,
|
||||
UserProvider: userProvider,
|
||||
UserRoleAssigner: roleAssigner,
|
||||
Store: signingKeyStore,
|
||||
Now: time.Now,
|
||||
URLVerifier: signURLVerifier,
|
||||
})
|
||||
|
||||
cspConfig, err := middleware.LoadCSPConfig(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to load CSP configuration.")
|
||||
}
|
||||
|
||||
return alice.New(
|
||||
chimiddleware.RealIP,
|
||||
chimiddleware.RequestID,
|
||||
// first make sure we log all requests and redirect to https if necessary
|
||||
otelhttp.NewMiddleware("proxy",
|
||||
otelhttp.WithTracerProvider(traceProvider),
|
||||
otelhttp.WithSpanNameFormatter(func(name string, r *http.Request) string {
|
||||
return fmt.Sprintf("%s %s", r.Method, r.URL.Path)
|
||||
}),
|
||||
),
|
||||
middleware.Tracer(traceProvider),
|
||||
pkgmiddleware.TraceContext,
|
||||
middleware.Instrumenter(metrics),
|
||||
middleware.AccessLog(logger),
|
||||
middleware.ContextLogger(logger),
|
||||
middleware.HTTPSRedirect,
|
||||
middleware.Security(cspConfig),
|
||||
router.Middleware(serviceSelector, cfg.PolicySelector, cfg.Policies, logger),
|
||||
middleware.Authentication(
|
||||
authenticators,
|
||||
middleware.CredentialsByUserAgent(cfg.AuthMiddleware.CredentialsByUserAgent),
|
||||
middleware.Logger(logger),
|
||||
middleware.OIDCIss(cfg.OIDC.Issuer),
|
||||
middleware.EnableBasicAuth(cfg.EnableBasicAuth || cfg.AuthMiddleware.AllowAppAuth),
|
||||
middleware.TraceProvider(traceProvider),
|
||||
),
|
||||
middleware.AccountResolver(
|
||||
middleware.Logger(logger),
|
||||
middleware.TraceProvider(traceProvider),
|
||||
middleware.UserProvider(userProvider),
|
||||
middleware.UserRoleAssigner(roleAssigner),
|
||||
middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo),
|
||||
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
|
||||
middleware.UserCS3Claim(cfg.UserCS3Claim),
|
||||
middleware.TenantOIDCClaim(cfg.TenantOIDCClaim),
|
||||
middleware.TenantIDMappingEnabled(cfg.TenantIDMappingEnabled),
|
||||
middleware.ServiceAccount(cfg.ServiceAccount),
|
||||
middleware.WithRevaGatewaySelector(gatewaySelector),
|
||||
middleware.AutoprovisionAccounts(cfg.AutoprovisionAccounts),
|
||||
middleware.MultiTenantEnabled(cfg.Commons.MultiTenantEnabled),
|
||||
middleware.EventsPublisher(publisher),
|
||||
),
|
||||
middleware.SelectorCookie(
|
||||
middleware.Logger(logger),
|
||||
middleware.TraceProvider(traceProvider),
|
||||
middleware.PolicySelectorConfig(*cfg.PolicySelector),
|
||||
),
|
||||
middleware.Policies(
|
||||
cfg.PoliciesMiddleware.Query,
|
||||
middleware.Logger(logger),
|
||||
middleware.TraceProvider(traceProvider),
|
||||
middleware.WithRevaGatewaySelector(gatewaySelector),
|
||||
middleware.PoliciesProviderService(policiesProviderClient),
|
||||
),
|
||||
// finally, trigger home creation when a user logs in
|
||||
middleware.CreateHome(
|
||||
middleware.Logger(logger),
|
||||
middleware.TraceProvider(traceProvider),
|
||||
middleware.WithRevaGatewaySelector(gatewaySelector),
|
||||
middleware.RoleQuotas(cfg.RoleQuotas),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
table.Header([]string{"Version", "Address", "Id"})
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"go-micro.dev/v4/client"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-" mask:"struct"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;PROXY_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
Debug Debug `yaml:"debug" mask:"struct"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Reva *shared.Reva `yaml:"reva"`
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
GrpcClient client.Client `yaml:"-"`
|
||||
|
||||
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
|
||||
Policies []Policy `yaml:"policies"`
|
||||
AdditionalPolicies []Policy `yaml:"additional_policies"`
|
||||
OIDC OIDC `yaml:"oidc"`
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
RoleAssignment RoleAssignment `yaml:"role_assignment"`
|
||||
PolicySelector *PolicySelector `yaml:"policy_selector"`
|
||||
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
|
||||
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"1.0.0"`
|
||||
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"1.0.0"`
|
||||
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"1.0.0"`
|
||||
TenantOIDCClaim string `yaml:"tenant_oidc_claim" env:"PROXY_TENANT_OIDC_CLAIM" desc:"JMESPath expression to extract the tenant ID from the OIDC token claims. When set, the extracted value is verified against the tenant ID returned by the user backend, rejecting requests where they do not match. Only relevant when multi-tenancy is enabled." introductionVersion:"6.1.0"`
|
||||
TenantIDMappingEnabled bool `yaml:"tenant_id_mapping_enabled" env:"PROXY_TENANT_ID_MAPPING_ENABLED" desc:"When set to 'true', the proxy will resolve the internal tenant ID from the external tenant ID provided in the OIDC claims by calling the TenantAPI before verifying the tenant. Use this when the external tenant ID in the OIDC token differs from the internal tenant ID stored on the user. Requires 'tenant_oidc_claim' to be set. Only relevant when multi-tenancy is enabled." introductionVersion:"6.1.0"`
|
||||
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"1.0.0" mask:"password"`
|
||||
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"1.0.0"`
|
||||
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
|
||||
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"1.0.0"`
|
||||
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"1.0.0"`
|
||||
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"1.0.0"`
|
||||
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
|
||||
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
|
||||
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"1.0.0"`
|
||||
CSPConfigFileOverrideLocation string `yaml:"csp_config_file_override_location" env:"PROXY_CSP_CONFIG_FILE_OVERRIDE_LOCATION" desc:"The location of the CSP configuration file override." introductionVersion:"4.0.0"`
|
||||
Events Events `yaml:"events"`
|
||||
|
||||
Context context.Context `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// Policy enables us to use multiple directors.
|
||||
type Policy struct {
|
||||
Name string `yaml:"name"`
|
||||
Routes []Route `yaml:"routes"`
|
||||
}
|
||||
|
||||
// Route defines forwarding routes
|
||||
type Route struct {
|
||||
Type RouteType `yaml:"type,omitempty"`
|
||||
// Method optionally limits the route to this HTTP method
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Endpoint string `yaml:"endpoint,omitempty"`
|
||||
// Backend is a static URL to forward the request to
|
||||
Backend string `yaml:"backend,omitempty"`
|
||||
// Service name to look up in the registry
|
||||
Service string `yaml:"service,omitempty"`
|
||||
ApacheVHost bool `yaml:"apache_vhost,omitempty"`
|
||||
Unprotected bool `yaml:"unprotected,omitempty"`
|
||||
AdditionalHeaders map[string]string `yaml:"additional_headers,omitempty"`
|
||||
RemoteUserHeader string `yaml:"remote_user_header,omitempty"`
|
||||
SkipXAccessToken bool `yaml:"skip_x_access_token"`
|
||||
}
|
||||
|
||||
// RouteType defines the type of route
|
||||
type RouteType string
|
||||
|
||||
const (
|
||||
// PrefixRoute are routes matched by a prefix
|
||||
PrefixRoute RouteType = "prefix"
|
||||
// QueryRoute are routes matched by a prefix and query parameters
|
||||
QueryRoute RouteType = "query"
|
||||
// RegexRoute are routes matched by a pattern
|
||||
RegexRoute RouteType = "regex"
|
||||
// DefaultRouteType is the PrefixRoute
|
||||
DefaultRouteType RouteType = PrefixRoute
|
||||
)
|
||||
|
||||
var (
|
||||
// RouteTypes is an array of the available route types
|
||||
RouteTypes = []RouteType{QueryRoute, RegexRoute, PrefixRoute}
|
||||
)
|
||||
|
||||
// AuthMiddleware configures the proxy http auth middleware.
|
||||
type AuthMiddleware struct {
|
||||
CredentialsByUserAgent map[string]string `yaml:"credentials_by_user_agent"`
|
||||
AllowAppAuth bool `yaml:"allow_app_auth" env:"PROXY_ENABLE_APP_AUTH" desc:"Allow app authentication. This can be used to authenticate 3rd party applications. Note that auth-app service must be running for this feature to work." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// PoliciesMiddleware configures the proxy's policies middleware.
|
||||
type PoliciesMiddleware struct {
|
||||
Query string `yaml:"query" env:"PROXY_POLICIES_QUERY" desc:"Defines the 'Complete Rules' variable defined in the rego rule set this step uses for its evaluation. Rules default to deny if the variable was not found." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
const (
|
||||
AccessTokenVerificationNone = "none"
|
||||
AccessTokenVerificationJWT = "jwt"
|
||||
// tdb:
|
||||
// AccessTokenVerificationIntrospect = "introspect"
|
||||
)
|
||||
|
||||
// OIDC is the config for the OpenID-Connect middleware. If set the proxy will try to authenticate every request
|
||||
// with the configured oidc-provider
|
||||
type OIDC struct {
|
||||
Issuer string `yaml:"issuer" env:"OC_URL;OC_OIDC_ISSUER;PROXY_OIDC_ISSUER" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP." introductionVersion:"1.0.0"`
|
||||
Insecure bool `yaml:"insecure" env:"OC_INSECURE;PROXY_OIDC_INSECURE" desc:"Disable TLS certificate validation for connections to the IDP. Note that this is not recommended for production environments." introductionVersion:"1.0.0"`
|
||||
AccessTokenVerifyMethod string `yaml:"access_token_verify_method" env:"PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD" desc:"Sets how OIDC access tokens should be verified. Possible values are 'none' and 'jwt'. When using 'none', no special validation apart from using it for accessing the IDP's userinfo endpoint will be done. When using 'jwt', it tries to parse the access token as a jwt token and verifies the signature using the keys published on the IDP's 'jwks_uri'." introductionVersion:"1.0.0"`
|
||||
SkipUserInfo bool `yaml:"skip_user_info" env:"PROXY_OIDC_SKIP_USER_INFO" desc:"Do not look up user claims at the userinfo endpoint and directly read them from the access token. Incompatible with 'PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=none'." introductionVersion:"1.0.0"`
|
||||
UserinfoCache *Cache `yaml:"user_info_cache"`
|
||||
JWKS JWKS `yaml:"jwks"`
|
||||
RewriteWellKnown bool `yaml:"rewrite_well_known" env:"PROXY_OIDC_REWRITE_WELLKNOWN" desc:"Enables rewriting the /.well-known/openid-configuration to the configured OIDC issuer. Needed by the Desktop Client, Android Client and iOS Client to discover the OIDC provider." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
type JWKS struct {
|
||||
RefreshInterval uint64 `yaml:"refresh_interval" env:"PROXY_OIDC_JWKS_REFRESH_INTERVAL" desc:"The interval for refreshing the JWKS (JSON Web Key Set) in minutes in the background via a new HTTP request to the IDP." introductionVersion:"1.0.0"`
|
||||
RefreshTimeout uint64 `yaml:"refresh_timeout" env:"PROXY_OIDC_JWKS_REFRESH_TIMEOUT" desc:"The timeout in seconds for an outgoing JWKS request." introductionVersion:"1.0.0"`
|
||||
RefreshRateLimit uint64 `yaml:"refresh_limit" env:"PROXY_OIDC_JWKS_REFRESH_RATE_LIMIT" desc:"Limits the rate in seconds at which refresh requests are performed for unknown keys. This is used to prevent malicious clients from imposing high network load on the IDP via КуСфера." introductionVersion:"1.0.0"`
|
||||
RefreshUnknownKID bool `yaml:"refresh_unknown_kid" env:"PROXY_OIDC_JWKS_REFRESH_UNKNOWN_KID" desc:"If set to 'true', the JWKS refresh request will occur every time an unknown KEY ID (KID) is seen. Always set a 'refresh_limit' when enabling this." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Cache is a TTL cache configuration.
|
||||
type Cache struct {
|
||||
Store string `yaml:"store" env:"OC_CACHE_STORE;PROXY_OIDC_USERINFO_CACHE_STORE" desc:"The type of the cache store. Supported values are: 'memory', 'redis-sentinel', 'nats-js-kv', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
|
||||
Nodes []string `yaml:"addresses" env:"OC_CACHE_STORE_NODES;PROXY_OIDC_USERINFO_CACHE_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
Database string `yaml:"database" env:"OC_CACHE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
|
||||
Table string `yaml:"table" env:"PROXY_OIDC_USERINFO_CACHE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
|
||||
TTL time.Duration `yaml:"ttl" env:"OC_CACHE_TTL;PROXY_OIDC_USERINFO_CACHE_TTL" desc:"Default time to live for user info in the user info cache. This value is only applied when the token expiration cannot be extracted from the access tokens (e.g. when non-JWT access tokes are used). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE;PROXY_OIDC_USERINFO_CACHE_DISABLE_PERSISTENCE" desc:"Disables persistence of the cache. Only applies when store type 'nats-js-kv' is configured. Defaults to false." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_CACHE_AUTH_USERNAME;PROXY_OIDC_USERINFO_CACHE_AUTH_USERNAME" desc:"The username to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_CACHE_AUTH_PASSWORD;PROXY_OIDC_USERINFO_CACHE_AUTH_PASSWORD" desc:"The password to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// RoleAssignment contains the configuration for how to assign roles to users during login
|
||||
type RoleAssignment struct {
|
||||
Driver string `yaml:"driver" env:"PROXY_ROLE_ASSIGNMENT_DRIVER" desc:"The mechanism that should be used to assign roles to user upon login. Supported values: 'default' or 'oidc'. 'default' will assign the role 'user' to users which don't have a role assigned at the time they login. 'oidc' will assign the role based on the value of a claim (configured via PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM) from the users OIDC claims." introductionVersion:"1.0.0"`
|
||||
OIDCRoleMapper OIDCRoleMapper `yaml:"oidc_role_mapper"`
|
||||
}
|
||||
|
||||
// OIDCRoleMapper contains the configuration for the "oidc" role assignment driver
|
||||
type OIDCRoleMapper struct {
|
||||
RoleClaim string `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment." introductionVersion:"1.0.0"`
|
||||
RolesMap []RoleMapping `yaml:"role_mapping" desc:"A list of mappings of КуСфера role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."`
|
||||
}
|
||||
|
||||
// RoleMapping defines which КуСфера role matches a specific claim value
|
||||
type RoleMapping struct {
|
||||
RoleName string `yaml:"role_name" desc:"The name of an КуСфера role that this mapping should apply for."`
|
||||
ClaimValue string `yaml:"claim_value" desc:"The value of the 'PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM' that matches the role defined in 'role_name'."`
|
||||
}
|
||||
|
||||
// AutoProvisionClaims defines which claims from the OIDC userinfo response should be used for auto-provisioning user accounts
|
||||
type AutoProvisionClaims struct {
|
||||
Username string `yaml:"username" env:"PROXY_AUTOPROVISION_CLAIM_USERNAME" desc:"The name of the OIDC claim that holds the username." introductionVersion:"1.0.0"`
|
||||
Email string `yaml:"email" env:"PROXY_AUTOPROVISION_CLAIM_EMAIL" desc:"The name of the OIDC claim that holds the email." introductionVersion:"1.0.0"`
|
||||
DisplayName string `yaml:"display_name" env:"PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME" desc:"The name of the OIDC claim that holds the display name." introductionVersion:"1.0.0"`
|
||||
Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// PolicySelector is the toplevel-configuration for different selectors
|
||||
type PolicySelector struct {
|
||||
Static *StaticSelectorConf `yaml:"static"`
|
||||
Claims *ClaimsSelectorConf `yaml:"claims"`
|
||||
Regex *RegexSelectorConf `yaml:"regex"`
|
||||
}
|
||||
|
||||
// StaticSelectorConf is the config for the static-policy-selector
|
||||
type StaticSelectorConf struct {
|
||||
Policy string `yaml:"policy"`
|
||||
}
|
||||
|
||||
// PreSignedURL is the config for the pre-signed url middleware
|
||||
type PreSignedURL struct {
|
||||
AllowedHTTPMethods []string `yaml:"allowed_http_methods"`
|
||||
Enabled bool `yaml:"enabled" env:"PROXY_ENABLE_PRESIGNEDURLS" desc:"Allow OCS to get a signing key to sign requests." introductionVersion:"1.0.0"`
|
||||
SigningKeys *SigningKeys `yaml:"signing_keys"`
|
||||
}
|
||||
|
||||
// SigningKeys is a store configuration.
|
||||
type SigningKeys struct {
|
||||
Store string `yaml:"store" env:"OC_CACHE_STORE;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE" desc:"The type of the signing key store. Supported values are: 'redis-sentinel', 'nats-js-kv' and 'qsferastoreservice' (deprecated). See the text description for details." introductionVersion:"1.0.0"`
|
||||
Nodes []string `yaml:"addresses" env:"OC_CACHE_STORE_NODES;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_NODES" desc:"A list of nodes to access the configured store. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
TTL time.Duration `yaml:"ttl" env:"OC_CACHE_TTL;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_TTL" desc:"Default time to live for signing keys. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_DISABLE_PERSISTENCE" desc:"Disables persistence of the store. Only applies when store type 'nats-js-kv' is configured. Defaults to true." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_CACHE_AUTH_USERNAME;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_CACHE_AUTH_PASSWORD;PROXY_PRESIGNEDURL_SIGNING_KEYS_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// ClaimsSelectorConf is the config for the claims-selector
|
||||
type ClaimsSelectorConf struct {
|
||||
DefaultPolicy string `yaml:"default_policy"`
|
||||
UnauthenticatedPolicy string `yaml:"unauthenticated_policy"`
|
||||
SelectorCookieName string `yaml:"selector_cookie_name"`
|
||||
}
|
||||
|
||||
// RegexSelectorConf is the config for the regex-selector
|
||||
type RegexSelectorConf struct {
|
||||
DefaultPolicy string `yaml:"default_policy"`
|
||||
MatchesPolicies []RegexRuleConf `yaml:"matches_policies"`
|
||||
UnauthenticatedPolicy string `yaml:"unauthenticated_policy"`
|
||||
SelectorCookieName string `yaml:"selector_cookie_name"`
|
||||
}
|
||||
|
||||
type RegexRuleConf struct {
|
||||
Priority int `yaml:"priority"`
|
||||
Property string `yaml:"property"`
|
||||
Match string `yaml:"match"`
|
||||
Policy string `yaml:"policy"`
|
||||
}
|
||||
|
||||
// ServiceAccount is the configuration for the used service account
|
||||
type ServiceAccount struct {
|
||||
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;PROXY_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
|
||||
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;PROXY_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;PROXY_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Set to a empty string to disable emitting events." introductionVersion:"1.0.0"`
|
||||
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;PROXY_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;PROXY_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;PROXY_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided PROXY_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;PROXY_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;PROXY_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;PROXY_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// CSP defines CSP header directives
|
||||
type CSP struct {
|
||||
Directives map[string][]string `yaml:"directives"`
|
||||
}
|
||||
|
||||
//go:embed csp.yaml
|
||||
var DefaultCSPConfig string
|
||||
@@ -0,0 +1,36 @@
|
||||
directives:
|
||||
child-src:
|
||||
- '''self'''
|
||||
connect-src:
|
||||
- '''self'''
|
||||
- 'blob:'
|
||||
- 'https://raw.githubusercontent.com/qsfera-eu/awesome-apps/'
|
||||
- 'https://update.qsfera.eu/'
|
||||
default-src:
|
||||
- '''none'''
|
||||
font-src:
|
||||
- '''self'''
|
||||
frame-ancestors:
|
||||
- '''self'''
|
||||
frame-src:
|
||||
- '''self'''
|
||||
- 'blob:'
|
||||
- 'https://embed.diagrams.net/'
|
||||
img-src:
|
||||
- '''self'''
|
||||
- 'data:'
|
||||
- 'blob:'
|
||||
- 'https://raw.githubusercontent.com/qsfera-eu/awesome-apps/'
|
||||
manifest-src:
|
||||
- '''self'''
|
||||
media-src:
|
||||
- '''self'''
|
||||
object-src:
|
||||
- '''self'''
|
||||
- 'blob:'
|
||||
script-src:
|
||||
- '''self'''
|
||||
- '''unsafe-inline'''
|
||||
style-src:
|
||||
- '''self'''
|
||||
- '''unsafe-inline'''
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"PROXY_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `mask:"password" yaml:"token" env:"PROXY_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"PROXY_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"PROXY_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/defaults"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/structs"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns a fully initialized default configuration
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig returns a basic default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9205",
|
||||
Token: "",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "0.0.0.0:9200",
|
||||
Root: "/",
|
||||
Namespace: "qsfera.web",
|
||||
TLSCert: path.Join(defaults.BaseDataPath(), "proxy", "server.crt"),
|
||||
TLSKey: path.Join(defaults.BaseDataPath(), "proxy", "server.key"),
|
||||
TLS: true,
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "proxy",
|
||||
},
|
||||
OIDC: config.OIDC{
|
||||
Issuer: "https://localhost:9200",
|
||||
|
||||
AccessTokenVerifyMethod: config.AccessTokenVerificationJWT,
|
||||
SkipUserInfo: false,
|
||||
UserinfoCache: &config.Cache{
|
||||
Store: "memory",
|
||||
Nodes: []string{"127.0.0.1:9233"},
|
||||
Database: "cache-userinfo",
|
||||
TTL: time.Second * 10,
|
||||
},
|
||||
JWKS: config.JWKS{
|
||||
RefreshInterval: 60, // minutes
|
||||
RefreshRateLimit: 60, // seconds
|
||||
RefreshTimeout: 10, // seconds
|
||||
RefreshUnknownKID: true,
|
||||
},
|
||||
},
|
||||
PolicySelector: nil,
|
||||
RoleAssignment: config.RoleAssignment{
|
||||
Driver: "default",
|
||||
// this default is only relevant when Driver is set to "oidc"
|
||||
OIDCRoleMapper: config.OIDCRoleMapper{
|
||||
RoleClaim: "roles",
|
||||
RolesMap: []config.RoleMapping{
|
||||
{RoleName: "admin", ClaimValue: "qsferaAdmin"},
|
||||
{RoleName: "spaceadmin", ClaimValue: "qsferaSpaceAdmin"},
|
||||
{RoleName: "user", ClaimValue: "qsferaUser"},
|
||||
{RoleName: "user-light", ClaimValue: "qsferaGuest"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Reva: shared.DefaultRevaConfig(),
|
||||
PreSignedURL: config.PreSignedURL{
|
||||
AllowedHTTPMethods: []string{"GET"},
|
||||
Enabled: true,
|
||||
SigningKeys: &config.SigningKeys{
|
||||
Store: "nats-js-kv", // signing keys are written by ocs, so we cannot use memory. It is not shared.
|
||||
Nodes: []string{"127.0.0.1:9233"},
|
||||
TTL: time.Hour * 12,
|
||||
DisablePersistence: true,
|
||||
},
|
||||
},
|
||||
AccountBackend: "cs3",
|
||||
UserOIDCClaim: "preferred_username",
|
||||
UserCS3Claim: "username",
|
||||
AutoprovisionAccounts: false,
|
||||
AutoProvisionClaims: config.AutoProvisionClaims{
|
||||
Username: "preferred_username",
|
||||
Email: "email",
|
||||
DisplayName: "name",
|
||||
Groups: "groups",
|
||||
},
|
||||
EnableBasicAuth: false,
|
||||
InsecureBackends: false,
|
||||
CSPConfigFileLocation: "",
|
||||
CSPConfigFileOverrideLocation: "",
|
||||
Events: config.Events{
|
||||
Endpoint: "127.0.0.1:9233",
|
||||
Cluster: "qsfera-cluster",
|
||||
EnableTLS: false,
|
||||
},
|
||||
AuthMiddleware: config.AuthMiddleware{
|
||||
AllowAppAuth: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPolicies returns the default proxy policies.
|
||||
func DefaultPolicies() []config.Policy {
|
||||
return []config.Policy{
|
||||
{
|
||||
Name: "default",
|
||||
Routes: []config.Route{
|
||||
{
|
||||
Endpoint: "/",
|
||||
Service: "qsfera.web.web",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/.well-known/ocm",
|
||||
Service: "qsfera.web.ocm",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/.well-known/webfinger",
|
||||
Service: "qsfera.web.webfinger",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/.well-known/openid-configuration",
|
||||
Service: "qsfera.web.idp",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/branding/logo",
|
||||
Service: "qsfera.web.web",
|
||||
},
|
||||
{
|
||||
Endpoint: "/konnect/",
|
||||
Service: "qsfera.web.idp",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/signin/",
|
||||
Service: "qsfera.web.idp",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/archiver",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
// reroute oc10 notifications endpoint to userlog service
|
||||
Endpoint: "/ocs/v2.php/apps/notifications/api/v1/notifications/sse",
|
||||
Service: "qsfera.sse.sse",
|
||||
},
|
||||
{
|
||||
// reroute oc10 notifications endpoint to userlog service
|
||||
Endpoint: "/ocs/v2.php/apps/notifications/api/v1/notifications",
|
||||
Service: "qsfera.web.userlog",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: "/ocs/v[12].php/cloud/user/signing-key", // only `user/signing-key` is left in qsfera-ocs
|
||||
Service: "qsfera.web.ocs",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: "/ocs/v[12].php/config",
|
||||
Service: "qsfera.web.frontend",
|
||||
Unprotected: true,
|
||||
},
|
||||
// OCM WAYF public endpoints
|
||||
{
|
||||
Endpoint: "/sciencemesh/federations",
|
||||
Service: "qsfera.web.ocm",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/sciencemesh/discover",
|
||||
Service: "qsfera.web.ocm",
|
||||
Unprotected: true,
|
||||
},
|
||||
// General sciencemesh endpoints
|
||||
{
|
||||
Endpoint: "/sciencemesh/",
|
||||
Service: "qsfera.web.ocm",
|
||||
},
|
||||
{
|
||||
Endpoint: "/ocm/",
|
||||
Service: "qsfera.web.ocm",
|
||||
},
|
||||
{
|
||||
Endpoint: "/ocs/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/remote.php/?preview=1",
|
||||
Service: "qsfera.web.webdav",
|
||||
},
|
||||
// TODO the actual REPORT goes to /dav/files/{username}, which is user specific ... how would this work in a spaces world?
|
||||
// TODO what paths are returned? the href contains the full path so it should be possible to return urls from other spaces?
|
||||
// TODO or we allow a REPORT on /dav/spaces to search all spaces and /dav/space/{spaceid} to search a specific space
|
||||
// send webdav REPORT requests to search service
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Method: "REPORT",
|
||||
Endpoint: "(/remote.php)?/(web)?dav",
|
||||
Service: "qsfera.web.webdav",
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/dav/?preview=1",
|
||||
Service: "qsfera.web.webdav",
|
||||
},
|
||||
{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/webdav/?preview=1",
|
||||
Service: "qsfera.web.webdav",
|
||||
},
|
||||
{
|
||||
Endpoint: "/remote.php/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/dav/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/webdav/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/status",
|
||||
Service: "qsfera.web.frontend",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/status.php",
|
||||
Service: "qsfera.web.frontend",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/index.php/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/apps/",
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/data",
|
||||
Service: "qsfera.web.frontend",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/app/list",
|
||||
Service: "qsfera.web.frontend",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/app/", // /app or /apps? frontend only handles /apps
|
||||
Service: "qsfera.web.frontend",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/v1beta1/extensions/org.libregraph/activities",
|
||||
Service: "qsfera.web.activitylog",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/v1.0/invitations",
|
||||
Service: "qsfera.web.invitations",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/",
|
||||
Service: "qsfera.web.graph",
|
||||
},
|
||||
{
|
||||
Endpoint: "/api/v0/settings",
|
||||
Service: "qsfera.web.settings",
|
||||
},
|
||||
{
|
||||
Endpoint: "/auth-app/tokens",
|
||||
Service: "qsfera.web.auth-app",
|
||||
},
|
||||
{
|
||||
Endpoint: "/wopi",
|
||||
Service: "qsfera.web.collaboration",
|
||||
Unprotected: true,
|
||||
SkipXAccessToken: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults adds default values to the configuration if they are not set yet
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.OIDC.UserinfoCache == nil && cfg.Commons != nil && cfg.Commons.Cache != nil {
|
||||
cfg.OIDC.UserinfoCache = &config.Cache{
|
||||
Store: cfg.Commons.Cache.Store,
|
||||
Nodes: cfg.Commons.Cache.Nodes,
|
||||
}
|
||||
} else if cfg.OIDC.UserinfoCache == nil {
|
||||
cfg.OIDC.UserinfoCache = &config.Cache{}
|
||||
}
|
||||
|
||||
if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
|
||||
cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
|
||||
}
|
||||
|
||||
if cfg.Reva == nil && cfg.Commons != nil {
|
||||
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
|
||||
}
|
||||
|
||||
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitizes the configuration
|
||||
func Sanitize(cfg *config.Config) {
|
||||
if cfg.Policies == nil {
|
||||
cfg.Policies = mergePolicies(DefaultPolicies(), cfg.AdditionalPolicies)
|
||||
}
|
||||
|
||||
if cfg.PolicySelector == nil {
|
||||
cfg.PolicySelector = &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: "default",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HTTP.Root != "/" {
|
||||
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
|
||||
}
|
||||
|
||||
// if the CSP config file path is not set, we check if the default file exists and set it if it does
|
||||
if cfg.CSPConfigFileLocation == "" {
|
||||
defaultCSPConfigFilePath := filepath.Join(defaults.BaseDataPath(), "proxy", "csp.yaml")
|
||||
if _, err := os.Stat(defaultCSPConfigFilePath); err == nil {
|
||||
cfg.CSPConfigFileLocation = defaultCSPConfigFilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergePolicies(policies []config.Policy, additionalPolicies []config.Policy) []config.Policy {
|
||||
for _, p := range additionalPolicies {
|
||||
found := false
|
||||
for i, po := range policies {
|
||||
if po.Name == p.Name {
|
||||
po.Routes = append(po.Routes, p.Routes...)
|
||||
policies[i] = po
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
policies = append(policies, p)
|
||||
}
|
||||
}
|
||||
return policies
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"PROXY_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
Root string `yaml:"root" env:"PROXY_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
TLSCert string `yaml:"tls_cert" env:"PROXY_TRANSPORT_TLS_CERT" desc:"Path/File name of the TLS server certificate (in PEM format) for the external http services. If not defined, the root directory derives from $OC_BASE_DATA_PATH/proxy." introductionVersion:"1.0.0"`
|
||||
TLSKey string `yaml:"tls_key" env:"PROXY_TRANSPORT_TLS_KEY" desc:"Path/File name for the TLS certificate key (in PEM format) for the server certificate to use for the external http services. If not defined, the root directory derives from $OC_BASE_DATA_PATH/proxy." introductionVersion:"1.0.0"`
|
||||
TLS bool `yaml:"tls" env:"PROXY_TLS" desc:"Enable/Disable HTTPS for external HTTP services. Must be set to 'true' if the built-in IDP service and no reverse proxy is used. See the text description for details." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config/defaults"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.MachineAuthAPIKey == "" {
|
||||
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationNone &&
|
||||
cfg.OIDC.AccessTokenVerifyMethod != config.AccessTokenVerificationJWT {
|
||||
return fmt.Errorf(
|
||||
"Invalid value '%s' for 'access_token_verify_method' in service %s. Possible values are: '%s' or '%s'.",
|
||||
cfg.OIDC.AccessTokenVerifyMethod, cfg.Service.Name,
|
||||
config.AccessTokenVerificationJWT, config.AccessTokenVerificationNone,
|
||||
)
|
||||
}
|
||||
if cfg.OIDC.AccessTokenVerifyMethod == "none" && cfg.OIDC.SkipUserInfo {
|
||||
return fmt.Errorf(
|
||||
"Incompatible value '%t' for 'skip_user_info' in service %s. Must be false when 'access_token_verify_method' is 'none'.",
|
||||
cfg.OIDC.SkipUserInfo, cfg.Service.Name,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.ServiceAccount.ServiceAccountID == "" {
|
||||
return shared.MissingServiceAccountID(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.ServiceAccount.ServiceAccountSecret == "" {
|
||||
return shared.MissingServiceAccountSecret(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.Commons.URLSigningSecret == "" {
|
||||
return shared.MissingURLSigningSecret(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "qsfera"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "proxy"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
Requests *prometheus.CounterVec
|
||||
Errors *prometheus.CounterVec
|
||||
Duration *prometheus.HistogramVec
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
Requests: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "requests_total",
|
||||
Help: "How many requests processed in total",
|
||||
}, []string{"method"}),
|
||||
Errors: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "errors_total",
|
||||
Help: "How many requests run into errors",
|
||||
}, []string{"method"}),
|
||||
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "duration_seconds",
|
||||
Help: "request duration in seconds",
|
||||
}, []string{"method"}),
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build Information",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
|
||||
// Initialize the metrics with 0
|
||||
m.Requests.WithLabelValues("GET").Add(0)
|
||||
m.Errors.WithLabelValues("GET").Add(0)
|
||||
|
||||
_ = prometheus.Register(m.Requests)
|
||||
_ = prometheus.Register(m.Errors)
|
||||
_ = prometheus.Register(m.Duration)
|
||||
_ = prometheus.Register(m.BuildInfo)
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// AccessLog is a middleware to log http requests at info level logging.
|
||||
func AccessLog(logger log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
requestID := middleware.GetReqID(r.Context())
|
||||
// add Request Id to all responses
|
||||
w.Header().Set(middleware.RequestIDHeader, requestID)
|
||||
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(wrap, r)
|
||||
|
||||
spanContext := trace.SpanContextFromContext(r.Context())
|
||||
logger.Info().
|
||||
Str("proto", r.Proto).
|
||||
Str(log.RequestIDString, requestID).
|
||||
Str("traceid", spanContext.TraceID().String()).
|
||||
Str("remote-addr", r.RemoteAddr).
|
||||
Str("method", r.Method).
|
||||
Int("status", wrap.Status()).
|
||||
Str("path", r.URL.Path).
|
||||
Dur("duration", time.Since(start)).
|
||||
Int("bytes", wrap.BytesWritten()).
|
||||
Msg("access-log")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
|
||||
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/userroles"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
// AccountResolver provides a middleware which mints a jwt and adds it to the proxied request based
|
||||
// on the oidc-claims
|
||||
func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
tracer := getTraceProvider(options).Tracer("proxy.middleware.account_resolver")
|
||||
|
||||
lastGroupSyncCache := ttlcache.New(
|
||||
ttlcache.WithTTL[string, struct{}](5*time.Minute),
|
||||
ttlcache.WithDisableTouchOnHit[string, struct{}](),
|
||||
)
|
||||
go lastGroupSyncCache.Start()
|
||||
|
||||
tenantIDCache := ttlcache.New(
|
||||
ttlcache.WithTTL[string, string](10*time.Minute),
|
||||
ttlcache.WithDisableTouchOnHit[string, string](),
|
||||
)
|
||||
go tenantIDCache.Start()
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &accountResolver{
|
||||
next: next,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
userProvider: options.UserProvider,
|
||||
userOIDCClaim: options.UserOIDCClaim,
|
||||
userCS3Claim: options.UserCS3Claim,
|
||||
tenantOIDCClaim: options.TenantOIDCClaim,
|
||||
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
|
||||
gatewaySelector: options.RevaGatewaySelector,
|
||||
serviceAccount: options.ServiceAccount,
|
||||
userRoleAssigner: options.UserRoleAssigner,
|
||||
autoProvisionAccounts: options.AutoprovisionAccounts,
|
||||
multiTenantEnabled: options.MultiTenantEnabled,
|
||||
lastGroupSyncCache: lastGroupSyncCache,
|
||||
tenantIDCache: tenantIDCache,
|
||||
eventsPublisher: options.EventsPublisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type accountResolver struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
userProvider backend.UserBackend
|
||||
userRoleAssigner userroles.UserRoleAssigner
|
||||
autoProvisionAccounts bool
|
||||
multiTenantEnabled bool
|
||||
tenantIDMappingEnabled bool
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
serviceAccount config.ServiceAccount
|
||||
userOIDCClaim string
|
||||
userCS3Claim string
|
||||
tenantOIDCClaim string
|
||||
// lastGroupSyncCache is used to keep track of when the last sync of group
|
||||
// memberships was done for a specific user. This is used to trigger a sync
|
||||
// with every single request.
|
||||
lastGroupSyncCache *ttlcache.Cache[string, struct{}]
|
||||
// tenantIDCache maps external tenant IDs (from OIDC claims) to internal tenant IDs.
|
||||
tenantIDCache *ttlcache.Cache[string, string]
|
||||
eventsPublisher events.Publisher
|
||||
}
|
||||
|
||||
func readStringClaim(path string, claims map[string]any) (string, error) {
|
||||
// happy path
|
||||
value, _ := claims[path].(string)
|
||||
if value != "" {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// try splitting path at .
|
||||
segments := oidc.SplitWithEscaping(path, ".", "\\")
|
||||
subclaims := claims
|
||||
lastSegment := len(segments) - 1
|
||||
for i := range segments {
|
||||
if i < lastSegment {
|
||||
if castedClaims, ok := subclaims[segments[i]].(map[string]any); ok {
|
||||
subclaims = castedClaims
|
||||
} else if castedClaims, ok := subclaims[segments[i]].(map[any]any); ok {
|
||||
subclaims = make(map[string]any, len(castedClaims))
|
||||
for k, v := range castedClaims {
|
||||
if s, ok := k.(string); ok {
|
||||
subclaims[s] = v
|
||||
} else {
|
||||
return "", fmt.Errorf("could not walk claims path, key '%v' is not a string", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if value, _ = subclaims[segments[i]].(string); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value, fmt.Errorf("claim path '%s' not set or empty", path)
|
||||
}
|
||||
|
||||
// TODO do not use the context to store values: https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
|
||||
func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
|
||||
claims := oidc.FromContext(ctx)
|
||||
user, ok := revactx.ContextGetUser(ctx)
|
||||
token, hasToken := revactx.ContextGetToken(ctx)
|
||||
req = req.WithContext(ctx)
|
||||
defer span.End()
|
||||
if claims == nil && !ok {
|
||||
span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil && claims != nil {
|
||||
value, err := readStringClaim(m.userOIDCClaim, claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not read user id claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err = m.userProvider.GetUserByClaims(req.Context(), m.userCS3Claim, value)
|
||||
|
||||
if errors.Is(err, backend.ErrAccountNotFound) {
|
||||
m.logger.Debug().Str("claim", m.userOIDCClaim).Str("value", value).Msg("User by claim not found")
|
||||
if !m.autoProvisionAccounts {
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning user")
|
||||
var newuser *cs3user.User
|
||||
newuser, err = m.userProvider.CreateUserFromClaims(req.Context(), claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Autoprovisioning user failed")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, token, err = m.userProvider.GetUserByClaims(req.Context(), "userid", newuser.Id.OpaqueId)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", newuser.Id.OpaqueId).Msg("Error getting token for autoprovisioned user")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, backend.ErrAccountDisabled) {
|
||||
m.logger.Debug().Interface("claims", claims).Msg("Disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
|
||||
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
|
||||
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// if a tenant claim is configured, verify it matches the tenant id on the resolved user
|
||||
if m.tenantOIDCClaim != "" {
|
||||
if err = m.verifyTenantClaim(req.Context(), user.GetId().GetTenantId(), claims); err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Tenant claim mismatch")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// update user if needed
|
||||
if m.autoProvisionAccounts {
|
||||
if err = m.userProvider.UpdateUserIfNeeded(req.Context(), user, claims); err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to update autoprovisioned user")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Only sync group memberships if the user has not been synced since the last cache invalidation
|
||||
if !m.lastGroupSyncCache.Has(user.GetId().GetOpaqueId()) {
|
||||
if err = m.userProvider.SyncGroupMemberships(req.Context(), user, claims); err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to sync group memberships for autoprovisioned user")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
m.lastGroupSyncCache.Set(user.GetId().GetOpaqueId(), struct{}{}, ttlcache.DefaultTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve the user's roles
|
||||
user, err = m.userRoleAssigner.UpdateUserRoleAssignment(ctx, user, claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user roles")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a new session, publish user login event
|
||||
if newSession := oidc.NewSessionFlagFromContext(ctx); newSession && m.eventsPublisher != nil {
|
||||
event := events.UserSignedIn{
|
||||
Executant: user.Id,
|
||||
Timestamp: utils.TimeToTS(time.Now()),
|
||||
}
|
||||
if err := events.Publish(req.Context(), m.eventsPublisher, event); err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not publish user signin event.")
|
||||
}
|
||||
}
|
||||
|
||||
// add user to context for selectors
|
||||
ctx = revactx.ContextSetUser(ctx, user)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
m.logger.Debug().Interface("claims", claims).Interface("user", user).Msg("associated claims with user")
|
||||
} else if user != nil && !hasToken {
|
||||
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
|
||||
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
|
||||
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If we already have a token (e.g. the app auth middleware adds the token to the context) there is no need
|
||||
// to get yet another one here.
|
||||
var err error
|
||||
_, token, err = m.userProvider.GetUserByClaims(req.Context(), "username", user.Username)
|
||||
if errors.Is(err, backend.ErrAccountDisabled) {
|
||||
m.logger.Debug().Interface("user", user).Msg("Disabled")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("enduser.id", user.GetId().GetOpaqueId()))
|
||||
|
||||
ri := router.ContextRoutingInfo(ctx)
|
||||
if ri.RemoteUserHeader() != "" {
|
||||
req.Header.Set(ri.RemoteUserHeader(), user.GetId().GetOpaqueId())
|
||||
}
|
||||
if !ri.SkipXAccessToken() {
|
||||
req.Header.Set(revactx.TokenHeader, token)
|
||||
}
|
||||
span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m accountResolver) verifyTenantClaim(ctx context.Context, userTenantID string, claims map[string]any) error {
|
||||
claimTenantID, err := readStringClaim(m.tenantOIDCClaim, claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read tenant claim: %w", err)
|
||||
}
|
||||
|
||||
internalTenantID := claimTenantID
|
||||
if m.tenantIDMappingEnabled {
|
||||
internalTenantID, err = m.resolveInternalTenantID(ctx, claimTenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve internal tenant id for external tenant id %q: %w", claimTenantID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if internalTenantID != userTenantID {
|
||||
return fmt.Errorf("tenant id from claim %q does not match user tenant id %q", claimTenantID, userTenantID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInternalTenantID maps an external tenant ID (as it appears in OIDC claims) to the
|
||||
// internal tenant ID stored on the user object by calling the gateway's TenantAPI.
|
||||
// Results are cached for 10 minutes to avoid repeated lookups on every request.
|
||||
// The call is authenticated using the configured service account.
|
||||
func (m accountResolver) resolveInternalTenantID(ctx context.Context, externalTenantID string) (string, error) {
|
||||
if item := m.tenantIDCache.Get(externalTenantID); item != nil {
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
gwc, err := m.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get gateway client: %w", err)
|
||||
}
|
||||
authCtx, err := utils.GetServiceUserContextWithContext(ctx, gwc, m.serviceAccount.ServiceAccountID, m.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not authenticate service account: %w", err)
|
||||
}
|
||||
resp, err := gwc.GetTenantByClaim(authCtx, &tenantpb.GetTenantByClaimRequest{
|
||||
Claim: "externalid",
|
||||
Value: externalTenantID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.GetStatus().GetCode() != rpcpb.Code_CODE_OK {
|
||||
return "", fmt.Errorf("TenantAPI returned status %s: %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
internalID := resp.GetTenant().GetId()
|
||||
m.tenantIDCache.Set(externalTenantID, internalID, ttlcache.DefaultTTL)
|
||||
return internalID, nil
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
|
||||
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
testIdP = "https://idx.example.com"
|
||||
testTenantA = "tenant-a"
|
||||
testTenantB = "tenant-b"
|
||||
testJWTSecret = "change-me"
|
||||
testSvcAccountID = "svc-account-id"
|
||||
testSvcAccountSecret = "svc-account-secret"
|
||||
testSvcAccountToken = "svc-account-token"
|
||||
)
|
||||
|
||||
func TestTokenIsAddedWithMailClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.Email, "mail", false)
|
||||
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.Email: "foo@example.com",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestTokenIsAddedWithUsernameClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.PreferredUsername, "username", false)
|
||||
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestTokenIsAddedWithDotUsernamePathClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, "li.un", "username", false)
|
||||
|
||||
// This is how lico adds the username to the access token
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
"li": map[string]any{
|
||||
"un": "foo",
|
||||
},
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestTokenIsAddedWithDottedUsernameClaim(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oidcClaim string
|
||||
// comment describing what the claim exercises
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "escaped dot treated as literal key",
|
||||
oidcClaim: "li\\.un",
|
||||
desc: "li\\.un escapes the dot so the claim is looked up as the literal key \"li.un\"",
|
||||
},
|
||||
{
|
||||
name: "dotted path falls back to literal key",
|
||||
oidcClaim: "li.un",
|
||||
desc: "li.un is first tried as a nested path; when \"un\" is absent under \"li\", it falls back to the literal key \"li.un\"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, tc.oidcClaim, "username", false)
|
||||
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
"li.un": "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.Contains(t, token, "eyJ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSkipOnNoClaims(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.Email, "mail", false)
|
||||
req, rw := mockRequest(nil)
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get("x-access-token")
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedOnUserNotFound(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.PreferredUsername, "username", false)
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedOnUserDisabled(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.PreferredUsername, "username", false)
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
|
||||
func TestInternalServerErrorOnMissingMailAndUsername(t *testing.T) {
|
||||
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.Email, "mail", false)
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedOnMissingTenantId(t *testing.T) {
|
||||
sut := newMockAccountResolver(
|
||||
&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
||||
Username: "foo",
|
||||
},
|
||||
nil, oidc.PreferredUsername, "username", true)
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
}
|
||||
|
||||
func TestTokenIsAddedWhenUserHasTenantId(t *testing.T) {
|
||||
sut := newMockAccountResolver(
|
||||
&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: testIdP,
|
||||
OpaqueId: "123",
|
||||
TenantId: "tenant1",
|
||||
},
|
||||
Username: "foo",
|
||||
},
|
||||
nil, oidc.PreferredUsername, "username", true)
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.Contains(t, token, "eyJ")
|
||||
}
|
||||
|
||||
func TestTenantClaimValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestTenant string
|
||||
wantToken bool
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "token added when tenant claim matches",
|
||||
requestTenant: testTenantA,
|
||||
wantToken: true,
|
||||
wantStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "unauthorized when tenant claim does not match",
|
||||
requestTenant: testTenantB,
|
||||
wantToken: false,
|
||||
wantStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
user := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: testIdP,
|
||||
OpaqueId: "123",
|
||||
TenantId: testTenantA,
|
||||
},
|
||||
Username: "foo",
|
||||
}
|
||||
|
||||
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ := tokenManager.MintToken(context.Background(), user, s)
|
||||
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
|
||||
ra := userRoleMocks.UserRoleAssigner{}
|
||||
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
||||
|
||||
sut := AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(&ub),
|
||||
UserRoleAssigner(&ra),
|
||||
UserOIDCClaim(oidc.PreferredUsername),
|
||||
UserCS3Claim("username"),
|
||||
TenantOIDCClaim("tenant_id"),
|
||||
MultiTenantEnabled(true),
|
||||
)(mockHandler{})
|
||||
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
"tenant_id": tc.requestTenant,
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
if tc.wantToken {
|
||||
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
|
||||
} else {
|
||||
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
|
||||
}
|
||||
assert.Equal(t, tc.wantStatusCode, rw.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string, multiTenant bool) http.Handler {
|
||||
tokenManager, _ := jwt.New(map[string]any{
|
||||
"secret": testJWTSecret,
|
||||
"expires": int64(60),
|
||||
})
|
||||
|
||||
token := ""
|
||||
if userBackendResult != nil {
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
|
||||
}
|
||||
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, token, userBackendErr)
|
||||
ub.On("GetUserRoles", mock.Anything, mock.Anything).Return(userBackendResult, nil)
|
||||
|
||||
ra := userRoleMocks.UserRoleAssigner{}
|
||||
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, nil)
|
||||
|
||||
return AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(&ub),
|
||||
UserRoleAssigner(&ra),
|
||||
SkipUserInfo(false),
|
||||
UserOIDCClaim(oidcclaim),
|
||||
UserCS3Claim(cs3claim),
|
||||
AutoprovisionAccounts(false),
|
||||
MultiTenantEnabled(multiTenant),
|
||||
)(mockHandler{})
|
||||
}
|
||||
|
||||
func mockRequest(claims map[string]any) (*http.Request, *httptest.ResponseRecorder) {
|
||||
if claims == nil {
|
||||
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
|
||||
}
|
||||
|
||||
ctx := oidc.NewContext(context.Background(), claims)
|
||||
ctx = router.SetRoutingInfo(ctx, router.RoutingInfo{})
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil).WithContext(ctx)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
return req, rw
|
||||
}
|
||||
|
||||
type mockHandler struct{}
|
||||
|
||||
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
|
||||
|
||||
func TestTenantIDMapping(t *testing.T) {
|
||||
const (
|
||||
externalTenantID = "external-tenant-x"
|
||||
internalTenantID = testTenantA
|
||||
)
|
||||
|
||||
user := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: testIdP,
|
||||
OpaqueId: "123",
|
||||
TenantId: internalTenantID,
|
||||
},
|
||||
Username: "foo",
|
||||
}
|
||||
|
||||
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ := tokenManager.MintToken(context.Background(), user, s)
|
||||
|
||||
newSUT := func(t *testing.T, gatewayClient gateway.GatewayAPIClient) http.Handler {
|
||||
t.Helper()
|
||||
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway") })
|
||||
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
|
||||
ra := userRoleMocks.UserRoleAssigner{}
|
||||
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
||||
|
||||
return AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(&ub),
|
||||
UserRoleAssigner(&ra),
|
||||
UserOIDCClaim(oidc.PreferredUsername),
|
||||
UserCS3Claim("username"),
|
||||
TenantOIDCClaim("tenant_id"),
|
||||
MultiTenantEnabled(true),
|
||||
TenantIDMappingEnabled(true),
|
||||
ServiceAccount(config.ServiceAccount{
|
||||
ServiceAccountID: testSvcAccountID,
|
||||
ServiceAccountSecret: testSvcAccountSecret,
|
||||
}),
|
||||
WithRevaGatewaySelector(gatewaySelector),
|
||||
)(mockHandler{})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tenantResponse *tenantpb.GetTenantByClaimResponse
|
||||
wantToken bool
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "token added when external tenant maps to user internal tenant",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Tenant: &tenantpb.Tenant{Id: internalTenantID, ExternalId: externalTenantID},
|
||||
},
|
||||
wantToken: true,
|
||||
wantStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "unauthorized when external tenant maps to a different internal tenant",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Tenant: &tenantpb.Tenant{Id: testTenantB, ExternalId: externalTenantID},
|
||||
},
|
||||
wantToken: false,
|
||||
wantStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "unauthorized when external tenant is not found",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_NOT_FOUND, Message: "not found"},
|
||||
},
|
||||
wantToken: false,
|
||||
wantStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gwc := &cs3mocks.GatewayAPIClient{}
|
||||
gwc.On("Authenticate", mock.Anything, &gateway.AuthenticateRequest{
|
||||
Type: "serviceaccounts",
|
||||
ClientId: testSvcAccountID,
|
||||
ClientSecret: testSvcAccountSecret,
|
||||
}).Return(&gateway.AuthenticateResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Token: testSvcAccountToken,
|
||||
}, nil)
|
||||
gwc.On("GetTenantByClaim", mock.Anything, &tenantpb.GetTenantByClaimRequest{
|
||||
Claim: "externalid",
|
||||
Value: externalTenantID,
|
||||
}).Return(tc.tenantResponse, nil)
|
||||
|
||||
req, rw := mockRequest(map[string]any{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
"tenant_id": externalTenantID,
|
||||
})
|
||||
newSUT(t, gwc).ServeHTTP(rw, req)
|
||||
|
||||
if tc.wantToken {
|
||||
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
|
||||
} else {
|
||||
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
|
||||
}
|
||||
assert.Equal(t, tc.wantStatusCode, rw.Code)
|
||||
gwc.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/userroles"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
// AppAuthAuthenticator defines the app auth authenticator
|
||||
type AppAuthAuthenticator struct {
|
||||
Logger log.Logger
|
||||
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
UserRoleAssigner userroles.UserRoleAssigner
|
||||
}
|
||||
|
||||
// Authenticate implements the authenticator interface to authenticate requests via app auth.
|
||||
func (m AppAuthAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
// The authentication of public path requests is handled by another authenticator.
|
||||
// Since we can't guarantee the order of execution of the authenticators, we better
|
||||
// implement an early return here for paths we can't authenticate in this authenticator.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
next, err := m.RevaGatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
authenticateResponse, err := next.Authenticate(r.Context(), &gateway.AuthenticateRequest{
|
||||
Type: "appauth",
|
||||
ClientId: username,
|
||||
ClientSecret: password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if authenticateResponse.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
|
||||
m.Logger.Debug().Str("msg", authenticateResponse.GetStatus().GetMessage()).Str("clientid", username).Msg("app auth failed")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user := authenticateResponse.GetUser()
|
||||
if user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user); err != nil {
|
||||
m.Logger.Error().Err(err).Str("clientid", username).Msg("app auth: failed to load user roles")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
ctx := revactx.ContextSetUser(r.Context(), user)
|
||||
ctx = revactx.ContextSetToken(ctx, authenticateResponse.GetToken())
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
return r, true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var _ = Describe("Authenticating requests", Label("AppAuthAuthenticator"), func() {
|
||||
var authenticator Authenticator
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
ra := &userRoleMocks.UserRoleAssigner{}
|
||||
ra.On("ApplyUserRole", mock.Anything, mock.Anything, mock.Anything).Return(&userv1beta1.User{}, nil)
|
||||
authenticator = AppAuthAuthenticator{
|
||||
Logger: log.NewLogger(),
|
||||
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return mockGatewayClient{
|
||||
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
|
||||
if authType != "appauth" {
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
}
|
||||
|
||||
if clientID == "test-user" && clientSecret == "AppPassword" {
|
||||
return "reva-token", rpcv1beta1.Code_CODE_OK
|
||||
}
|
||||
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
UserRoleAssigner: ra,
|
||||
}
|
||||
})
|
||||
|
||||
When("the request contains correct data", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.SetBasicAuth("test-user", "AppPassword")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
user, ok := revactx.ContextGetUser(req2.Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(user).ToNot(BeNil())
|
||||
token, ok := revactx.ContextGetToken(req2.Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(token).To(Equal("reva-token"))
|
||||
})
|
||||
})
|
||||
|
||||
When("the request contains incorrect data", func() {
|
||||
It("should not successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.SetBasicAuth("test-user", "WrongAppPassword")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(false))
|
||||
Expect(req2).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/qsfera/server/services/proxy/pkg/webdav"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// SupportedAuthStrategies stores configured challenges.
|
||||
SupportedAuthStrategies []string
|
||||
|
||||
// ProxyWwwAuthenticate is a list of endpoints that do not rely on reva underlying authentication, such as ocs.
|
||||
// services that fallback to reva authentication are declared in the "frontend" command on КуСфера. It is a list of
|
||||
// regexp.Regexp which are safe to use concurrently.
|
||||
ProxyWwwAuthenticate = []regexp.Regexp{*regexp.MustCompile("/ocs/v[12].php/cloud/")}
|
||||
|
||||
_publicPaths = [...]string{
|
||||
"/dav/public-files/",
|
||||
"/remote.php/dav/ocm/",
|
||||
"/dav/ocm/",
|
||||
"/ocm/",
|
||||
"/remote.php/dav/public-files/",
|
||||
"/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
|
||||
"/ocs/v1.php/cloud/capabilities",
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
// WwwAuthenticate captures the Www-Authenticate header string.
|
||||
WwwAuthenticate = "Www-Authenticate"
|
||||
)
|
||||
|
||||
// Authenticator is the common interface implemented by all request authenticators.
|
||||
type Authenticator interface {
|
||||
// Authenticate is used to authenticate incoming HTTP requests.
|
||||
// The Authenticator may augment the request with user info or anything related to the
|
||||
// authentication and return the augmented request.
|
||||
Authenticate(*http.Request) (*http.Request, bool)
|
||||
}
|
||||
|
||||
// Authentication is a higher order authentication middleware.
|
||||
func Authentication(auths []Authenticator, opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
configureSupportedChallenges(options)
|
||||
tracer := getTraceProvider(options).Tracer("proxy.middleware.authentication")
|
||||
|
||||
spanOpts := []trace.SpanStartOption{
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), spanOpts...)
|
||||
r = r.WithContext(ctx)
|
||||
defer span.End()
|
||||
|
||||
ri := router.ContextRoutingInfo(ctx)
|
||||
if isOIDCTokenAuth(r) || ri.IsRouteUnprotected() || r.Method == "OPTIONS" {
|
||||
// Either this is a request that does not need any authentication or
|
||||
// the authentication for this request is handled by the IdP.
|
||||
span.SetAttributes(attribute.Bool("routeunprotected", true))
|
||||
span.End()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
for _, a := range auths {
|
||||
if req, ok := a.Authenticate(r); ok {
|
||||
span.End()
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !isPublicPath(r.URL.Path) {
|
||||
// Failed basic authentication attempts receive the Www-Authenticate header in the response
|
||||
var touch bool
|
||||
caser := cases.Title(language.Und)
|
||||
for k, v := range options.CredentialsByUserAgent {
|
||||
if strings.Contains(k, r.UserAgent()) {
|
||||
removeSuperfluousAuthenticate(w)
|
||||
w.Header().Add("Www-Authenticate", fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(v), r.Host))
|
||||
touch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// if the request is not bound to any user agent, write all available challenges
|
||||
if !touch {
|
||||
writeSupportedAuthenticateHeader(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range SupportedAuthStrategies {
|
||||
userAgentAuthenticateLockIn(w, r, options.CredentialsByUserAgent, s)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
// if the request is a PROPFIND return a WebDAV error code.
|
||||
// TODO: The proxy has to be smart enough to detect when a request is directed towards a webdav server
|
||||
// and react accordingly.
|
||||
if webdav.IsWebdavRequest(r) {
|
||||
b, err := webdav.Marshal(webdav.Exception{
|
||||
Code: webdav.SabredavPermissionDenied,
|
||||
Message: "Authentication error",
|
||||
})
|
||||
|
||||
webdav.HandleWebdavError(w, b, err)
|
||||
}
|
||||
|
||||
if r.ProtoMajor == 1 {
|
||||
// https://github.com/owncloud/ocis/issues/5066
|
||||
// https://github.com/golang/go/blob/d5de62df152baf4de6e9fe81933319b86fd95ae4/src/net/http/server.go#L1357-L1417
|
||||
// https://github.com/golang/go/issues/15527
|
||||
|
||||
defer r.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The token auth endpoint uses basic auth for clients, see https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest
|
||||
// > The Client MUST authenticate to the Token Endpoint using the HTTP Basic method, as described in 2.3.1 of OAuth 2.0.
|
||||
func isOIDCTokenAuth(req *http.Request) bool {
|
||||
return req.URL.Path == "/konnect/v1/token"
|
||||
}
|
||||
|
||||
func isPublicPath(p string) bool {
|
||||
for _, pp := range _publicPaths {
|
||||
if strings.HasPrefix(p, pp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// configureSupportedChallenges adds known authentication challenges to the current session.
|
||||
func configureSupportedChallenges(options Options) {
|
||||
if options.OIDCIss != "" {
|
||||
SupportedAuthStrategies = append(SupportedAuthStrategies, "bearer")
|
||||
}
|
||||
|
||||
if options.EnableBasicAuth {
|
||||
SupportedAuthStrategies = append(SupportedAuthStrategies, "basic")
|
||||
}
|
||||
}
|
||||
|
||||
func writeSupportedAuthenticateHeader(w http.ResponseWriter, r *http.Request) {
|
||||
caser := cases.Title(language.Und)
|
||||
for _, s := range SupportedAuthStrategies {
|
||||
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
|
||||
w.Header().Add(WwwAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(s), r.Host))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeSuperfluousAuthenticate(w http.ResponseWriter) {
|
||||
w.Header().Del(WwwAuthenticate)
|
||||
}
|
||||
|
||||
// userAgentLocker aids in dependency injection for helper methods. The set of fields is arbitrary and the only relation
|
||||
// they share is to fulfill their duty and lock a User-Agent to its correct challenge if configured.
|
||||
type userAgentLocker struct {
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
locks map[string]string // locks represents a reva user-agent:challenge mapping.
|
||||
fallback string
|
||||
}
|
||||
|
||||
// userAgentAuthenticateLockIn sets Www-Authenticate according to configured user agents. This is useful for the case of
|
||||
// legacy clients that do not support protocols like OIDC or OAuth and want to lock a given user agent to a challenge
|
||||
// such as basic. For more context check https://github.com/cs3org/reva/pull/1350
|
||||
func userAgentAuthenticateLockIn(w http.ResponseWriter, r *http.Request, locks map[string]string, fallback string) {
|
||||
u := userAgentLocker{
|
||||
w: w,
|
||||
r: r,
|
||||
locks: locks,
|
||||
fallback: fallback,
|
||||
}
|
||||
|
||||
for _, r := range ProxyWwwAuthenticate {
|
||||
evalRequestURI(u, r)
|
||||
}
|
||||
}
|
||||
|
||||
func evalRequestURI(l userAgentLocker, r regexp.Regexp) {
|
||||
if !r.MatchString(l.r.RequestURI) {
|
||||
return
|
||||
}
|
||||
caser := cases.Title(language.Und)
|
||||
for k, v := range l.locks {
|
||||
if strings.Contains(k, l.r.UserAgent()) {
|
||||
removeSuperfluousAuthenticate(l.w)
|
||||
l.w.Header().Add(WwwAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(v), l.r.Host))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getTraceProvider(o Options) trace.TracerProvider {
|
||||
if o.TraceProvider != nil {
|
||||
return o.TraceProvider
|
||||
}
|
||||
return trace.NewNoopTracerProvider()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go-micro.dev/v4/store"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var _ = Describe("authentication helpers", func() {
|
||||
DescribeTable("isPublicPath should recognize public paths",
|
||||
func(input string, expected bool) {
|
||||
isPublic := isPublicPath(input)
|
||||
Expect(isPublic).To(Equal(expected))
|
||||
},
|
||||
Entry("public files path", "/remote.php/dav/public-files/", true),
|
||||
Entry("public files path without remote.php", "/remote.php/dav/public-files/", true),
|
||||
Entry("token info path", "/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
|
||||
Entry("token info path", "/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
|
||||
Entry("capabilities", "/ocs/v1.php/cloud/capabilities", true),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Authenticating requests", Label("Authentication"), func() {
|
||||
var (
|
||||
authenticators []Authenticator
|
||||
)
|
||||
oc := oidcmocks.OIDCClient{}
|
||||
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
|
||||
oidc.RegClaimsWithSID{
|
||||
SessionID: "a-session-id",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
|
||||
},
|
||||
}, jwt.MapClaims{
|
||||
"sid": "a-session-id",
|
||||
"exp": 1147483647,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
|
||||
&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "IdpId",
|
||||
OpaqueId: "OpaqueId",
|
||||
},
|
||||
Username: "testuser",
|
||||
Mail: "testuser@example.com",
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
|
||||
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
|
||||
logger := log.NewLogger()
|
||||
authenticators = []Authenticator{
|
||||
BasicAuthenticator{
|
||||
Logger: logger,
|
||||
UserProvider: &ub,
|
||||
},
|
||||
&OIDCAuthenticator{
|
||||
OIDCIss: "http://idp.example.com",
|
||||
Logger: logger,
|
||||
oidcClient: &oc,
|
||||
userInfoCache: store.NewMemoryStore(),
|
||||
skipUserInfo: true,
|
||||
},
|
||||
PublicShareAuthenticator{
|
||||
Logger: logger,
|
||||
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return mockGatewayClient{
|
||||
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
|
||||
if authType != "publicshares" {
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
}
|
||||
|
||||
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
|
||||
return "exampletoken", rpcv1beta1.Code_CODE_OK
|
||||
}
|
||||
|
||||
if clientID == "sharetoken" && clientSecret == "password|" {
|
||||
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
|
||||
}
|
||||
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
When("the public request must contains correct data", func() {
|
||||
It("ensures the context oidc data when the Bearer authentication is successful", func() {
|
||||
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
|
||||
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
handler := Authentication(authenticators,
|
||||
EnableBasicAuth(true),
|
||||
)
|
||||
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
|
||||
"sid": "a-session-id",
|
||||
"exp": int64(1147483647),
|
||||
}))
|
||||
}))
|
||||
rr := httptest.NewRecorder()
|
||||
testHandler.ServeHTTP(rr, req)
|
||||
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
|
||||
})
|
||||
It("ensures the context oidc data when user the Basic authentication is successful", func() {
|
||||
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
|
||||
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
|
||||
req.SetBasicAuth("testuser", "testpassword")
|
||||
|
||||
handler := Authentication(authenticators,
|
||||
EnableBasicAuth(true),
|
||||
)
|
||||
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
|
||||
"email": "testuser@example.com",
|
||||
"qsferauuid": "OpaqueId",
|
||||
"iss": "IdpId",
|
||||
"preferred_username": "testuser",
|
||||
}))
|
||||
}))
|
||||
rr := httptest.NewRecorder()
|
||||
testHandler.ServeHTTP(rr, req)
|
||||
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
|
||||
})
|
||||
It("ensures the x-access-token header when public-token URL parameter is set", func() {
|
||||
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
|
||||
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
|
||||
|
||||
handler := Authentication(authenticators,
|
||||
EnableBasicAuth(true),
|
||||
)
|
||||
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
|
||||
}))
|
||||
rr := httptest.NewRecorder()
|
||||
testHandler.ServeHTTP(rr, req)
|
||||
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
|
||||
})
|
||||
It("ensures the x-access-token header when public-token URL parameter and BasicAuth are set", func() {
|
||||
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
|
||||
req.SetBasicAuth("public", "examples3cr3t")
|
||||
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
|
||||
|
||||
handler := Authentication(authenticators,
|
||||
EnableBasicAuth(true),
|
||||
)
|
||||
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
|
||||
}))
|
||||
rr := httptest.NewRecorder()
|
||||
testHandler.ServeHTTP(rr, req)
|
||||
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
|
||||
})
|
||||
It("ensures the x-access-token header when public-token BasicAuth is set", func() {
|
||||
req := httptest.NewRequest("GET", "http://example.com/archiver", http.NoBody)
|
||||
req.Header.Set("public-token", "sharetoken")
|
||||
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
|
||||
|
||||
handler := Authentication(authenticators,
|
||||
EnableBasicAuth(true),
|
||||
)
|
||||
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
|
||||
}))
|
||||
rr := httptest.NewRecorder()
|
||||
testHandler.ServeHTTP(rr, req)
|
||||
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
)
|
||||
|
||||
// BasicAuthenticator is the authenticator responsible for HTTP Basic authentication.
|
||||
type BasicAuthenticator struct {
|
||||
Logger log.Logger
|
||||
UserProvider backend.UserBackend
|
||||
UserCS3Claim string
|
||||
UserOIDCClaim string
|
||||
}
|
||||
|
||||
// Authenticate implements the authenticator interface to authenticate requests via basic auth.
|
||||
func (m BasicAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
|
||||
if isPublicPath(r.URL.Path) && isPublicWithShareToken(r) {
|
||||
// The authentication of public path requests is handled by another authenticator.
|
||||
// Since we can't guarantee the order of execution of the authenticators, we better
|
||||
// implement an early return here for paths we can't authenticate in this authenticator.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
login, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, _, err := m.UserProvider.Authenticate(r.Context(), login, password)
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "basic").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("failed to authenticate request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fake oidc claims
|
||||
claims := map[string]any{
|
||||
oidc.Iss: user.Id.Idp,
|
||||
oidc.PreferredUsername: user.Username,
|
||||
oidc.Email: user.Mail,
|
||||
oidc.QsferaUUID: user.Id.OpaqueId,
|
||||
}
|
||||
|
||||
if m.UserCS3Claim == "userid" {
|
||||
// set the custom user claim only if users will be looked up by the userid on the CS3api
|
||||
// OpaqueId contains the userid configured in STORAGE_LDAP_USER_SCHEMA_UID
|
||||
claims[m.UserOIDCClaim] = user.Id.OpaqueId
|
||||
|
||||
}
|
||||
m.Logger.Debug().
|
||||
Str("authenticator", "basic").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("successfully authenticated request")
|
||||
return r.WithContext(oidc.NewContext(r.Context(), claims)), true
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
|
||||
)
|
||||
|
||||
var _ = Describe("Authenticating requests", Label("BasicAuthenticator"), func() {
|
||||
var authenticator Authenticator
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
|
||||
&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "IdpId",
|
||||
OpaqueId: "OpaqueId",
|
||||
},
|
||||
Username: "testuser",
|
||||
Mail: "testuser@example.com",
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
|
||||
|
||||
BeforeEach(func() {
|
||||
authenticator = BasicAuthenticator{
|
||||
Logger: log.NewLogger(),
|
||||
UserProvider: &ub,
|
||||
}
|
||||
})
|
||||
|
||||
When("the request contains correct data", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.SetBasicAuth("testuser", "testpassword")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
It("adds claims to the request context", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.SetBasicAuth("testuser", "testpassword")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
Expect(valid).To(Equal(true))
|
||||
|
||||
claims := oidc.FromContext(req2.Context())
|
||||
Expect(claims).ToNot(BeNil())
|
||||
Expect(claims[oidc.Iss]).To(Equal("IdpId"))
|
||||
Expect(claims[oidc.PreferredUsername]).To(Equal("testuser"))
|
||||
Expect(claims[oidc.Email]).To(Equal("testuser@example.com"))
|
||||
Expect(claims[oidc.QsferaUUID]).To(Equal("OpaqueId"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
// ContextLogger is a middleware to use a logger associated with the request's
|
||||
// context which includes general information of the request.
|
||||
func ContextLogger(logger log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := logger.With().
|
||||
Str("remoteAddr", r.RemoteAddr).
|
||||
Str(log.RequestIDString, r.Header.Get("X-Request-ID")).
|
||||
Str("proto", r.Proto).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Str("query", r.URL.RawQuery).
|
||||
Str("fragment", r.URL.Fragment).
|
||||
Logger().WithContext(r.Context())
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// CreateHome provides a middleware which sends a CreateHome request to the reva gateway
|
||||
func CreateHome(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
tracer := getTraceProvider(options).Tracer("proxy.middleware.create_home")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &createHome{
|
||||
next: next,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
revaGatewaySelector: options.RevaGatewaySelector,
|
||||
roleQuotas: options.RoleQuotas,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type createHome struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
revaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
roleQuotas map[string]uint64
|
||||
}
|
||||
|
||||
func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
|
||||
req = req.WithContext(ctx)
|
||||
defer span.End()
|
||||
if !m.shouldServe(req) {
|
||||
span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
|
||||
// we need to pass the token to authenticate the CreateHome request.
|
||||
//ctx := tokenpkg.ContextSetToken(r.Context(), token)
|
||||
ctx = metadata.AppendToOutgoingContext(req.Context(), revactx.TokenHeader, token)
|
||||
|
||||
createHomeReq := &provider.CreateHomeRequest{}
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if ok {
|
||||
roleIDs, err := m.getUserRoles(u)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to get roles for user")
|
||||
errorcode.GeneralException.Render(w, req, http.StatusInternalServerError, "Unauthorized")
|
||||
return
|
||||
}
|
||||
if limit, hasLimit := m.checkRoleQuotaLimit(roleIDs); hasLimit {
|
||||
createHomeReq.Opaque = utils.AppendPlainToOpaque(nil, "quota", strconv.FormatUint(limit, 10))
|
||||
}
|
||||
}
|
||||
|
||||
client, err := m.revaGatewaySelector.Next()
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("error selecting next gateway client")
|
||||
} else {
|
||||
createHomeRes, err := client.CreateHome(ctx, createHomeReq)
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("error calling CreateHome")
|
||||
} else if createHomeRes.Status.Code != rpc.Code_CODE_OK {
|
||||
err := status.NewErrorFromCode(createHomeRes.Status.Code, "gateway")
|
||||
if createHomeRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS {
|
||||
m.logger.Err(err).Msg("error when calling Createhome")
|
||||
}
|
||||
}
|
||||
}
|
||||
span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m createHome) shouldServe(req *http.Request) bool {
|
||||
return req.Header.Get(revactx.TokenHeader) != ""
|
||||
}
|
||||
|
||||
func (m createHome) getUserRoles(user *userv1beta1.User) ([]string, error) {
|
||||
var roleIDs []string
|
||||
if err := utils.ReadJSONFromOpaque(user.Opaque, "roles", &roleIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmp := make(map[string]struct{})
|
||||
for _, id := range roleIDs {
|
||||
tmp[id] = struct{}{}
|
||||
}
|
||||
|
||||
dedup := make([]string, 0, len(tmp))
|
||||
for k := range tmp {
|
||||
dedup = append(dedup, k)
|
||||
}
|
||||
return dedup, nil
|
||||
}
|
||||
|
||||
func (m createHome) checkRoleQuotaLimit(roleIDs []string) (uint64, bool) {
|
||||
if len(roleIDs) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
id := roleIDs[0] // At the moment a user can only have one role.
|
||||
quota, ok := m.roleQuotas[id]
|
||||
return quota, ok
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPSRedirect redirects insecure requests to https
|
||||
func HTTPSRedirect(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
proto := req.Header.Get("x-forwarded-proto")
|
||||
if proto == "http" || proto == "HTTP" {
|
||||
http.Redirect(res, req, fmt.Sprintf("https://%s%s", req.Host, req.URL), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(res, req)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/services/proxy/pkg/metrics"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Instrumenter provides a middleware to create metrics
|
||||
func Instrumenter(m metrics.Metrics) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
m.Requests.With(prometheus.Labels{"method": r.Method}).Inc()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
m.Duration.With(prometheus.Labels{"method": r.Method}).Observe(float64(time.Since(start).Seconds()))
|
||||
if ww.Status() >= 500 {
|
||||
m.Errors.With(prometheus.Labels{"method": r.Method}).Inc()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Middleware Suite")
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go-micro.dev/v4/store"
|
||||
"golang.org/x/crypto/sha3"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/staticroutes"
|
||||
)
|
||||
|
||||
const (
|
||||
_headerAuthorization = "Authorization"
|
||||
_bearerPrefix = "Bearer "
|
||||
)
|
||||
|
||||
// NewOIDCAuthenticator returns a ready to use authenticator which can handle OIDC authentication.
|
||||
func NewOIDCAuthenticator(opts ...Option) *OIDCAuthenticator {
|
||||
options := newOptions(opts...)
|
||||
|
||||
return &OIDCAuthenticator{
|
||||
Logger: options.Logger,
|
||||
userInfoCache: options.UserInfoCache,
|
||||
DefaultTokenCacheTTL: options.DefaultAccessTokenTTL,
|
||||
HTTPClient: options.HTTPClient,
|
||||
OIDCIss: options.OIDCIss,
|
||||
oidcClient: options.OIDCClient,
|
||||
AccessTokenVerifyMethod: options.AccessTokenVerifyMethod,
|
||||
skipUserInfo: options.SkipUserInfo,
|
||||
TimeFunc: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCAuthenticator is an authenticator responsible for OIDC authentication.
|
||||
type OIDCAuthenticator struct {
|
||||
Logger log.Logger
|
||||
HTTPClient *http.Client
|
||||
OIDCIss string
|
||||
userInfoCache store.Store
|
||||
DefaultTokenCacheTTL time.Duration
|
||||
oidcClient oidc.OIDCClient
|
||||
AccessTokenVerifyMethod string
|
||||
skipUserInfo bool
|
||||
TimeFunc func() time.Time
|
||||
}
|
||||
|
||||
func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]any, bool, error) {
|
||||
var claims map[string]any
|
||||
|
||||
// use a 64 bytes long hash to have 256-bit collision resistance.
|
||||
hash := make([]byte, 64)
|
||||
sha3.ShakeSum256(hash, []byte(token))
|
||||
encodedHash := base64.URLEncoding.EncodeToString(hash)
|
||||
|
||||
record, err := m.userInfoCache.Read(encodedHash)
|
||||
if err != nil && err != store.ErrNotFound {
|
||||
m.Logger.Error().Err(err).Msg("could not read from userinfo cache")
|
||||
}
|
||||
if len(record) > 0 {
|
||||
if err = msgpack.Unmarshal(record[0].Value, &claims); err == nil {
|
||||
m.Logger.Debug().Interface("claims", claims).Msg("cache hit for userinfo")
|
||||
if verifyExpiresAt(claims, m.TimeFunc()) {
|
||||
return claims, false, nil
|
||||
}
|
||||
m.Logger.Debug().Msg("cached userinfo claims expired, ignoring cache")
|
||||
} else {
|
||||
m.Logger.Error().Err(err).Msg("failed to unmarshal cached userinfo, ignoring cache")
|
||||
}
|
||||
}
|
||||
|
||||
aClaims, claims, err := m.oidcClient.VerifyAccessToken(req.Context(), token)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, "failed to verify access token")
|
||||
}
|
||||
|
||||
if !m.skipUserInfo {
|
||||
oauth2Token := &oauth2.Token{
|
||||
AccessToken: token,
|
||||
}
|
||||
|
||||
userInfo, err := m.oidcClient.UserInfo(
|
||||
context.WithValue(req.Context(), oauth2.HTTPClient, m.HTTPClient),
|
||||
oauth2.StaticTokenSource(oauth2Token),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, "failed to get userinfo")
|
||||
}
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
return nil, false, errors.Wrap(err, "failed to unmarshal userinfo claims")
|
||||
}
|
||||
}
|
||||
|
||||
expiration := m.extractExpiration(aClaims)
|
||||
// always set an exp claim
|
||||
claims["exp"] = expiration.Unix()
|
||||
go func() {
|
||||
if d, err := msgpack.Marshal(claims); err != nil {
|
||||
m.Logger.Error().Err(err).Msg("failed to marshal claims for userinfo cache")
|
||||
} else {
|
||||
err = m.userInfoCache.Write(&store.Record{
|
||||
Key: encodedHash,
|
||||
Value: d,
|
||||
Expiry: time.Until(expiration),
|
||||
})
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("failed to write to userinfo cache")
|
||||
}
|
||||
|
||||
// fail if creating the storage key fails,
|
||||
// it means there is no subject and no session.
|
||||
//
|
||||
// ok: {key: ".sessionId"}
|
||||
// ok: {key: "subject."}
|
||||
// ok: {key: "subject.sessionId"}
|
||||
// fail: {key: "."}
|
||||
subjectSessionKey, err := staticroutes.NewRecordKey(aClaims.Subject, aClaims.SessionID)
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("failed to build subject.session")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.userInfoCache.Write(&store.Record{
|
||||
Key: subjectSessionKey,
|
||||
Value: []byte(encodedHash),
|
||||
Expiry: time.Until(expiration),
|
||||
}); err != nil {
|
||||
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// If we get here this was a new login (or a renewal of the token)
|
||||
// add a flag about that to the claims, to be able to distinguish
|
||||
// it in the accountresolver middleware
|
||||
|
||||
m.Logger.Debug().Interface("claims", claims).Msg("extracted claims")
|
||||
return claims, true, nil
|
||||
}
|
||||
|
||||
// extractExpiration tries to extract the expriration time from the access token
|
||||
// If the access token does not have an exp claim it will fallback to the configured
|
||||
// default expiration
|
||||
func (m OIDCAuthenticator) extractExpiration(aClaims oidc.RegClaimsWithSID) time.Time {
|
||||
defaultExpiration := time.Now().Add(m.DefaultTokenCacheTTL)
|
||||
if aClaims.ExpiresAt != nil {
|
||||
m.Logger.Debug().Str("exp", aClaims.ExpiresAt.String()).Msg("Expiration Time from access_token")
|
||||
return aClaims.ExpiresAt.Time
|
||||
}
|
||||
return defaultExpiration
|
||||
}
|
||||
|
||||
func verifyExpiresAt(claims map[string]any, cmp time.Time) bool {
|
||||
var expiry time.Time
|
||||
switch v := claims["exp"].(type) {
|
||||
case nil:
|
||||
return false
|
||||
case int64:
|
||||
expiry = time.Unix(v, 0)
|
||||
case uint32:
|
||||
expiry = time.Unix(int64(v), 0)
|
||||
}
|
||||
return cmp.Before(expiry)
|
||||
}
|
||||
|
||||
func (m OIDCAuthenticator) shouldServe(req *http.Request) bool {
|
||||
if m.OIDCIss == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
header := req.Header.Get(_headerAuthorization)
|
||||
return strings.HasPrefix(header, _bearerPrefix)
|
||||
}
|
||||
|
||||
// Authenticate implements the authenticator interface to authenticate requests via oidc auth.
|
||||
func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
|
||||
// there is no bearer token on the request,
|
||||
if !m.shouldServe(r) {
|
||||
// The authentication of public path requests is handled by another authenticator.
|
||||
// Since we can't guarantee the order of execution of the authenticators, we better
|
||||
// implement an early return here for paths we can't authenticate in this authenticator.
|
||||
return nil, false
|
||||
}
|
||||
token := strings.TrimPrefix(r.Header.Get(_headerAuthorization), _bearerPrefix)
|
||||
if token == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
claims, newSession, err := m.getClaims(token, r)
|
||||
if err != nil {
|
||||
host, port, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "oidc").
|
||||
Str("path", r.URL.Path).
|
||||
Str("user_agent", r.UserAgent()).
|
||||
Str("client.address", r.Header.Get("X-Forwarded-For")).
|
||||
Str("network.peer.address", host).
|
||||
Str("network.peer.port", port).
|
||||
Msg("failed to authenticate the request")
|
||||
return nil, false
|
||||
}
|
||||
m.Logger.Debug().
|
||||
Str("authenticator", "oidc").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("successfully authenticated request")
|
||||
|
||||
ctx := r.Context()
|
||||
if newSession {
|
||||
ctx = oidc.NewContextSessionFlag(ctx, true)
|
||||
}
|
||||
|
||||
return r.WithContext(oidc.NewContext(ctx, claims)), true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
var _ = Describe("Authenticating requests", Label("OIDCAuthenticator"), func() {
|
||||
var authenticator Authenticator
|
||||
|
||||
oc := oidcmocks.OIDCClient{}
|
||||
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
|
||||
oidc.RegClaimsWithSID{
|
||||
SessionID: "a-session-id",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
|
||||
},
|
||||
}, jwt.MapClaims{
|
||||
"sid": "a-session-id",
|
||||
"exp": 1147483647,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
/*
|
||||
// to test with skipUserInfo: true, we need to also use an interface so we can mock the UserInfo.Claim call
|
||||
oc.On("UserInfo", mock.Anything, mock.Anything).Return(
|
||||
&oidc.UserInfo{
|
||||
Subject: "my-sub",
|
||||
EmailVerified: true,
|
||||
Email: "test@example.org",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
*/
|
||||
|
||||
BeforeEach(func() {
|
||||
authenticator = &OIDCAuthenticator{
|
||||
OIDCIss: "http://idp.example.com",
|
||||
Logger: log.NewLogger(),
|
||||
oidcClient: &oc,
|
||||
userInfoCache: store.NewMemoryStore(),
|
||||
skipUserInfo: true,
|
||||
}
|
||||
})
|
||||
|
||||
When("the request contains correct data", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
It("should skip authenticate if the header ShareToken is set", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
req.Header.Set(headerShareToken, "sharetoken")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
// TODO Should the authentication of public path requests is handled by another authenticator?
|
||||
//Expect(valid).To(Equal(false))
|
||||
//Expect(req2).To(BeNil())
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
It("should skip authenticate if the 'public-token' is set", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
// TODO Should the authentication of public path requests is handled by another authenticator?
|
||||
//Expect(valid).To(Equal(false))
|
||||
//Expect(req2).To(BeNil())
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
policiessvc "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/userroles"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go-micro.dev/v4/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
// Logger to use for logging, must be set
|
||||
Logger log.Logger
|
||||
// PolicySelectorConfig for using the policy selector
|
||||
PolicySelector config.PolicySelector
|
||||
// HTTPClient to use for communication with the oidcAuth provider
|
||||
HTTPClient *http.Client
|
||||
// UserProvider backend to use for resolving User
|
||||
UserProvider backend.UserBackend
|
||||
// UserRoleAssigner to user for assign a users default role
|
||||
UserRoleAssigner userroles.UserRoleAssigner
|
||||
// SettingsRoleService for the roles API in settings
|
||||
SettingsRoleService settingssvc.RoleService
|
||||
// PoliciesProviderService for policy evaluation
|
||||
PoliciesProviderService policiessvc.PoliciesProviderService
|
||||
// OIDCClient to fetch user info and verify tokens, must be set for the oidc_auth middleware
|
||||
OIDCClient oidc.OIDCClient
|
||||
// OIDCIss is the oidcAuth-issuer
|
||||
OIDCIss string
|
||||
// RevaGatewaySelector to send requests to the reva gateway
|
||||
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
// PreSignedURLConfig to configure the middleware
|
||||
PreSignedURLConfig config.PreSignedURL
|
||||
// UserOIDCClaim to read from the oidc claims
|
||||
UserOIDCClaim string
|
||||
// UserCS3Claim to use when looking up a user in the CS3 API
|
||||
UserCS3Claim string
|
||||
// TenantOIDCClaim is a JMESPath expression to extract the tenant ID from the OIDC claims.
|
||||
// When set, the extracted value is verified against the tenant ID on the resolved user.
|
||||
TenantOIDCClaim string
|
||||
// AutoprovisionAccounts when an accountResolver does not exist.
|
||||
AutoprovisionAccounts bool
|
||||
// EnableBasicAuth to allow basic auth
|
||||
EnableBasicAuth bool
|
||||
// DefaultAccessTokenTTL is used to calculate the expiration when an access token has no expiration set
|
||||
DefaultAccessTokenTTL time.Duration
|
||||
// UserInfoCache sets the access token cache store
|
||||
UserInfoCache store.Store
|
||||
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
|
||||
CredentialsByUserAgent map[string]string
|
||||
// AccessTokenVerifyMethod configures how access_tokens should be verified but the oidc_auth middleware.
|
||||
// Possible values currently: "jwt" and "none"
|
||||
AccessTokenVerifyMethod string
|
||||
// JWKS sets the options for fetching the JWKS from the IDP
|
||||
JWKS config.JWKS
|
||||
// RoleQuotas hold userid:quota mappings. These will be used when provisioning new users.
|
||||
// The users will get as much quota as is set for their role.
|
||||
RoleQuotas map[string]uint64
|
||||
// TraceProvider sets the tracing provider.
|
||||
TraceProvider trace.TracerProvider
|
||||
// SkipUserInfo prevents the oidc middleware from querying the userinfo endpoint and read any claims directly from the access token instead
|
||||
SkipUserInfo bool
|
||||
// MultiTenantEnabled causes the account resolve middleware to reject users that don't have a tenant id assigned
|
||||
MultiTenantEnabled bool
|
||||
// TenantIDMappingEnabled causes the account resolver to resolve the internal tenant ID from the external
|
||||
// tenant ID in the OIDC claims via the gateway's TenantAPI before comparing it to the user's stored tenant ID.
|
||||
TenantIDMappingEnabled bool
|
||||
// ServiceAccount holds credentials used to authenticate internal service calls (e.g. TenantAPI lookups).
|
||||
ServiceAccount config.ServiceAccount
|
||||
EventsPublisher events.Publisher
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(l log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// PolicySelectorConfig provides a function to set the policy selector config option.
|
||||
func PolicySelectorConfig(cfg config.PolicySelector) Option {
|
||||
return func(o *Options) {
|
||||
o.PolicySelector = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPClient provides a function to set the http client config option.
|
||||
func HTTPClient(c *http.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.HTTPClient = c
|
||||
}
|
||||
}
|
||||
|
||||
// SettingsRoleService provides a function to set the role service option.
|
||||
func SettingsRoleService(rc settingssvc.RoleService) Option {
|
||||
return func(o *Options) {
|
||||
o.SettingsRoleService = rc
|
||||
}
|
||||
}
|
||||
|
||||
// PoliciesProviderService provides a function to set the policies provider option.
|
||||
func PoliciesProviderService(pps policiessvc.PoliciesProviderService) Option {
|
||||
return func(o *Options) {
|
||||
o.PoliciesProviderService = pps
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCClient provides a function to set the oidc client option.
|
||||
func OIDCClient(val oidc.OIDCClient) Option {
|
||||
return func(o *Options) {
|
||||
o.OIDCClient = val
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCIss sets the oidcAuth issuer url
|
||||
func OIDCIss(iss string) Option {
|
||||
return func(o *Options) {
|
||||
o.OIDCIss = iss
|
||||
}
|
||||
}
|
||||
|
||||
// CredentialsByUserAgent sets UserAgentChallenges.
|
||||
func CredentialsByUserAgent(v map[string]string) Option {
|
||||
return func(o *Options) {
|
||||
o.CredentialsByUserAgent = v
|
||||
}
|
||||
}
|
||||
|
||||
// WithRevaGatewaySelector provides a function to set the reva gateway service selector option.
|
||||
func WithRevaGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.RevaGatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// PreSignedURLConfig provides a function to set the PreSignedURL config
|
||||
func PreSignedURLConfig(cfg config.PreSignedURL) Option {
|
||||
return func(o *Options) {
|
||||
o.PreSignedURLConfig = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// UserOIDCClaim provides a function to set the UserClaim config
|
||||
func UserOIDCClaim(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.UserOIDCClaim = val
|
||||
}
|
||||
}
|
||||
|
||||
// UserCS3Claim provides a function to set the UserClaimType config
|
||||
func UserCS3Claim(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.UserCS3Claim = val
|
||||
}
|
||||
}
|
||||
|
||||
// TenantOIDCClaim provides a function to set the TenantOIDCClaim config
|
||||
func TenantOIDCClaim(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.TenantOIDCClaim = val
|
||||
}
|
||||
}
|
||||
|
||||
// AutoprovisionAccounts provides a function to set the AutoprovisionAccounts config
|
||||
func AutoprovisionAccounts(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.AutoprovisionAccounts = val
|
||||
}
|
||||
}
|
||||
|
||||
// EnableBasicAuth provides a function to set the EnableBasicAuth config
|
||||
func EnableBasicAuth(enableBasicAuth bool) Option {
|
||||
return func(o *Options) {
|
||||
o.EnableBasicAuth = enableBasicAuth
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAccessTokenTTL provides a function to set the DefaultAccessTokenTTL
|
||||
func DefaultAccessTokenTTL(ttl time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.DefaultAccessTokenTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfoCache provides a function to set the UserInfoCache
|
||||
func UserInfoCache(val store.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.UserInfoCache = val
|
||||
}
|
||||
}
|
||||
|
||||
// UserProvider sets the accounts user provider
|
||||
func UserProvider(up backend.UserBackend) Option {
|
||||
return func(o *Options) {
|
||||
o.UserProvider = up
|
||||
}
|
||||
}
|
||||
|
||||
// UserRoleAssigner sets the mechanism for assigning the default user roles
|
||||
func UserRoleAssigner(ra userroles.UserRoleAssigner) Option {
|
||||
return func(o *Options) {
|
||||
o.UserRoleAssigner = ra
|
||||
}
|
||||
}
|
||||
|
||||
// AccessTokenVerifyMethod set the mechanism for access token verification
|
||||
func AccessTokenVerifyMethod(method string) Option {
|
||||
return func(o *Options) {
|
||||
o.AccessTokenVerifyMethod = method
|
||||
}
|
||||
}
|
||||
|
||||
// RoleQuotas sets the role quota mapping setting
|
||||
func RoleQuotas(roleQuotas map[string]uint64) Option {
|
||||
return func(o *Options) {
|
||||
o.RoleQuotas = roleQuotas
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider sets the tracing provider.
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = tp
|
||||
}
|
||||
}
|
||||
|
||||
// SkipUserInfo sets the skipUserInfo flag.
|
||||
func SkipUserInfo(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.SkipUserInfo = val
|
||||
}
|
||||
}
|
||||
|
||||
// MultiTenantEnabled sets the MultiTenantEnabled flag.
|
||||
func MultiTenantEnabled(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.MultiTenantEnabled = val
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceAccount sets the service account credentials used for authenticated internal calls.
|
||||
func ServiceAccount(sa config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.ServiceAccount = sa
|
||||
}
|
||||
}
|
||||
|
||||
// TenantIDMappingEnabled sets the TenantIDMappingEnabled flag.
|
||||
// When true, the account resolver resolves the internal tenant ID from the external tenant ID
|
||||
// provided in the OIDC claims by calling the gateway's TenantAPI, instead of comparing directly.
|
||||
func TenantIDMappingEnabled(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.TenantIDMappingEnabled = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventsPublisher sets the events publisher.
|
||||
func EventsPublisher(ep events.Publisher) Option {
|
||||
return func(o *Options) {
|
||||
o.EventsPublisher = ep
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/render"
|
||||
tusd "github.com/tus/tusd/v2/pkg/handler"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
|
||||
pService "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
|
||||
"github.com/qsfera/server/services/webdav/pkg/net"
|
||||
)
|
||||
|
||||
type (
|
||||
// RequestDenied struct for OdataErrorMain
|
||||
RequestDenied struct {
|
||||
Error RequestDeniedError `json:"error"`
|
||||
}
|
||||
|
||||
// RequestDeniedError struct for RequestDenied
|
||||
RequestDeniedError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
// The structure of this object is service-specific
|
||||
Innererror map[string]any `json:"innererror,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const DeniedMessage = "Operation denied due to security policies"
|
||||
|
||||
// Policies verifies if a request is granted or not.
|
||||
func Policies(qs string, opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
logger := options.Logger
|
||||
tracer := getTraceProvider(options).Tracer("proxy.middleware.policies")
|
||||
gatewaySelector := options.RevaGatewaySelector
|
||||
policiesProviderClient := options.PoliciesProviderService
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
|
||||
r = r.WithContext(ctx)
|
||||
defer span.End()
|
||||
if qs == "" {
|
||||
span.End()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := &pService.EvaluateRequest{
|
||||
Query: qs,
|
||||
Environment: &pMessage.Environment{
|
||||
Request: &pMessage.Request{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
},
|
||||
Stage: pMessage.Stage_STAGE_HTTP,
|
||||
},
|
||||
}
|
||||
|
||||
resource := &pMessage.Resource{}
|
||||
|
||||
// tus
|
||||
meta := tusd.ParseMetadataHeader(r.Header.Get(net.HeaderUploadMetadata))
|
||||
resource.Name = meta["filename"]
|
||||
|
||||
// name is part of the request path
|
||||
if resource.Name == "" && filepath.Ext(r.URL.Path) != "" {
|
||||
resource.Name = filepath.Base(r.URL.Path)
|
||||
}
|
||||
|
||||
// no resource info in path, stat the resource and try to obtain the file information.
|
||||
// this should only be used as last bastion, every request goes through the proxy and doing stats is expensive!
|
||||
// needed for:
|
||||
// - if a single resource is shared -> the url only contains the resourceID (spaceRef)
|
||||
if resource.Name == "" && filepath.Ext(r.URL.Path) == "" && r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/remote.php/dav/spaces") {
|
||||
client, err := gatewaySelector.Next()
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("error selecting next gateway client")
|
||||
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
resourceID, err := storagespace.ParseID(strings.TrimPrefix(r.URL.Path, "/remote.php/dav/spaces/"))
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("error parsing the resourceId")
|
||||
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if resourceID.StorageId == "" && resourceID.SpaceId == utils.ShareStorageSpaceID {
|
||||
resourceID.StorageId = utils.ShareStorageProviderID
|
||||
}
|
||||
|
||||
token := r.Header.Get(revactx.TokenHeader)
|
||||
ctx := metadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, token)
|
||||
sRes, err := client.Stat(ctx, &provider.StatRequest{
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &resourceID,
|
||||
},
|
||||
})
|
||||
|
||||
resource.Name = sRes.GetInfo().GetName()
|
||||
}
|
||||
|
||||
req.Environment.Resource = resource
|
||||
|
||||
if user, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
req.Environment.User = &pMessage.User{
|
||||
Id: &pMessage.User_ID{
|
||||
OpaqueId: user.GetId().GetOpaqueId(),
|
||||
},
|
||||
Username: user.GetUsername(),
|
||||
Mail: user.GetMail(),
|
||||
DisplayName: user.GetDisplayName(),
|
||||
Groups: user.GetGroups(),
|
||||
}
|
||||
}
|
||||
|
||||
rsp, err := policiesProviderClient.Evaluate(r.Context(), req)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("error evaluating request")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !rsp.Result {
|
||||
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
|
||||
return
|
||||
}
|
||||
span.End()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RenderError writes a Policies ErrorObject to the response writer
|
||||
func RenderError(w http.ResponseWriter, r *http.Request, evaluateReq *pService.EvaluateRequest, status int, msg string) {
|
||||
filename := evaluateReq.Environment.GetResource().GetName()
|
||||
if filename == "" {
|
||||
filename = path.Base(evaluateReq.Environment.GetRequest().GetPath())
|
||||
}
|
||||
|
||||
innererror := map[string]any{
|
||||
"date": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
innererror["request-id"] = middleware.GetReqID(r.Context())
|
||||
innererror["method"] = evaluateReq.Environment.GetRequest().GetMethod()
|
||||
innererror["filename"] = filename
|
||||
innererror["path"] = evaluateReq.Environment.GetRequest().GetPath()
|
||||
|
||||
resp := &RequestDenied{
|
||||
Error: RequestDeniedError{
|
||||
Code: "deniedByPolicy",
|
||||
Message: msg,
|
||||
Innererror: innererror,
|
||||
},
|
||||
}
|
||||
render.Status(r, status)
|
||||
render.JSON(w, r, resp)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
. "github.com/onsi/gomega"
|
||||
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
|
||||
policiesPG "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
|
||||
"github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0/mocks"
|
||||
"github.com/qsfera/server/services/proxy/pkg/middleware"
|
||||
"github.com/qsfera/server/services/webdav/pkg/net"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go-micro.dev/v4/client"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func TestPolicies_NoQuery_PassThrough(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, _, _ := prepare("")
|
||||
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
|
||||
|
||||
g.Expect(responseRecorder.Code).To(Equal(http.StatusOK))
|
||||
}
|
||||
|
||||
func TestPolicies_ErrorsOnEvaluationError(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, policiesProviderService, _ := prepare("any")
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
|
||||
nil,
|
||||
errors.New("any"),
|
||||
)
|
||||
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
|
||||
|
||||
g.Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
|
||||
}
|
||||
|
||||
func TestPolicies_ErrorsOnDeny(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, policiesProviderService, _ := prepare("any")
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
|
||||
&policiesPG.EvaluateResponse{},
|
||||
nil,
|
||||
)
|
||||
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
|
||||
|
||||
result := responseRecorder.Result()
|
||||
defer func() {
|
||||
g.Expect(result.Body.Close()).ToNot(HaveOccurred())
|
||||
}()
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(data).To(ContainSubstring(middleware.DeniedMessage))
|
||||
g.Expect(responseRecorder.Code).To(Equal(http.StatusForbidden))
|
||||
}
|
||||
|
||||
func TestPolicies_EvaluationEnvironment_HTTPStage(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, policiesProviderService, _ := prepare("any")
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
|
||||
g.Expect(in.Environment.Stage).To(Equal(pMessage.Stage_STAGE_HTTP))
|
||||
|
||||
return &policiesPG.EvaluateResponse{Result: false}, nil
|
||||
},
|
||||
)
|
||||
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
|
||||
}
|
||||
|
||||
func TestPolicies_EvaluationEnvironment_Request(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, policiesProviderService, _ := prepare("any")
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
|
||||
g.Expect(in.Environment.Request.Method).To(Equal(http.MethodDelete))
|
||||
g.Expect(in.Environment.Request.Path).To(Equal("/whatever"))
|
||||
|
||||
return &policiesPG.EvaluateResponse{Result: false}, nil
|
||||
},
|
||||
)
|
||||
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodDelete, "/whatever", nil))
|
||||
}
|
||||
|
||||
func TestPolicies_EvaluationEnvironment_Resource(t *testing.T) {
|
||||
var g = NewWithT(t)
|
||||
|
||||
policiesMiddleware, policiesProviderService, _ := prepare("any")
|
||||
|
||||
// tus metadata
|
||||
{
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/remote.php/dav/spaces", nil)
|
||||
request.Header.Set(net.HeaderUploadMetadata, fmt.Sprintf("filename %v", base64.StdEncoding.EncodeToString([]byte("tus-file-name.png"))))
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
|
||||
g.Expect(in.Environment.Resource.Name).To(Equal("tus-file-name.png"))
|
||||
|
||||
return &policiesPG.EvaluateResponse{Result: false}, nil
|
||||
},
|
||||
).Once()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, request)
|
||||
}
|
||||
|
||||
// url path
|
||||
{
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
|
||||
g.Expect(in.Environment.Resource.Name).To(Equal("simple-file-name.png"))
|
||||
|
||||
return &policiesPG.EvaluateResponse{Result: false}, nil
|
||||
},
|
||||
).Once()
|
||||
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodPut, "/remote.php/dav/spaces/simple-file-name.png", nil))
|
||||
}
|
||||
}
|
||||
|
||||
func prepare(q string) (http.Handler, *mocks.PoliciesProviderService, *cs3mocks.GatewayAPIClient) {
|
||||
|
||||
// mocked gatewaySelector
|
||||
gatewayClient := &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
defer pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
|
||||
// mocked policiesProviderService
|
||||
policiesProviderService := &mocks.PoliciesProviderService{}
|
||||
|
||||
// spin up middleware
|
||||
policiesMiddleware := middleware.Policies(
|
||||
q,
|
||||
middleware.WithRevaGatewaySelector(gatewaySelector),
|
||||
middleware.PoliciesProviderService(policiesProviderService),
|
||||
)(mockHandler{})
|
||||
|
||||
return policiesMiddleware, policiesProviderService, gatewayClient
|
||||
}
|
||||
|
||||
type mockHandler struct{}
|
||||
|
||||
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
|
||||
@@ -0,0 +1,133 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
headerRevaAccessToken = revactx.TokenHeader
|
||||
headerShareToken = "public-token"
|
||||
basicAuthPasswordPrefix = "password|"
|
||||
authenticationType = "publicshares"
|
||||
|
||||
_paramSignature = "signature"
|
||||
_paramExpiration = "expiration"
|
||||
)
|
||||
|
||||
// PublicShareAuthenticator is the authenticator which can authenticate public share requests.
|
||||
// It will add the share owner into the request context.
|
||||
type PublicShareAuthenticator struct {
|
||||
Logger log.Logger
|
||||
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// The archiver is able to create archives from public shares in which case it needs to use the
|
||||
// PublicShareAuthenticator. It might however also be called using "normal" authentication or
|
||||
// using signed url, which are handled by other middleware. For this reason we can't just
|
||||
// handle `/archiver` with the `isPublicPath()` check.
|
||||
func isPublicShareArchive(r *http.Request) bool {
|
||||
if strings.HasPrefix(r.URL.Path, "/archiver") {
|
||||
if r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The app open requests can be made in public share contexts. For that the PublicShareAuthenticator needs to
|
||||
// augment the request context.
|
||||
// The app open requests can also be made in authenticated context. In these cases the PublicShareAuthenticator
|
||||
// needs to ignore the request.
|
||||
func isPublicShareAppOpen(r *http.Request) bool {
|
||||
return (strings.HasPrefix(r.URL.Path, "/app/open") || strings.HasPrefix(r.URL.Path, "/app/new")) &&
|
||||
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
|
||||
}
|
||||
|
||||
// The public-files requests can also be made in authenticated context. In these cases the OIDCAuthenticator and
|
||||
// the BasicAuthenticator needs to ignore the request when the headerShareToken exist.
|
||||
func isPublicWithShareToken(r *http.Request) bool {
|
||||
return (strings.HasPrefix(r.URL.Path, "/dav/public-files") || strings.HasPrefix(r.URL.Path, "/remote.php/dav/public-files")) &&
|
||||
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
|
||||
}
|
||||
|
||||
// Authenticate implements the authenticator interface to authenticate requests via public share auth.
|
||||
func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
|
||||
if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
shareToken := r.Header.Get(headerShareToken)
|
||||
if shareToken == "" {
|
||||
shareToken = query.Get(headerShareToken)
|
||||
}
|
||||
|
||||
if shareToken == "" {
|
||||
// If the share token is not set then we don't need to inject the user to
|
||||
// the request context so we can just continue with the request.
|
||||
return r, true
|
||||
}
|
||||
|
||||
var sharePassword string
|
||||
if signature := query.Get(_paramSignature); signature != "" {
|
||||
expiration := query.Get(_paramExpiration)
|
||||
if expiration == "" {
|
||||
a.Logger.Warn().Str("signature", signature).Msg("cannot do signature auth without the expiration")
|
||||
return nil, false
|
||||
}
|
||||
sharePassword = strings.Join([]string{"signature", signature, expiration}, "|")
|
||||
} else {
|
||||
// We can ignore the username since it is always set to "public" in public shares.
|
||||
_, password, ok := r.BasicAuth()
|
||||
|
||||
sharePassword = basicAuthPasswordPrefix
|
||||
if ok {
|
||||
sharePassword += password
|
||||
}
|
||||
}
|
||||
|
||||
client, err := a.RevaGatewaySelector.Next()
|
||||
if err != nil {
|
||||
a.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "public_share").
|
||||
Str("public_share_token", shareToken).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("could not select next gateway client")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
authResp, err := client.Authenticate(r.Context(), &gateway.AuthenticateRequest{
|
||||
Type: authenticationType,
|
||||
ClientId: shareToken,
|
||||
ClientSecret: sharePassword,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
a.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "public_share").
|
||||
Str("public_share_token", shareToken).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("failed to authenticate request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
r.Header.Add(headerRevaAccessToken, authResp.Token)
|
||||
|
||||
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("enduser.id", "public"))
|
||||
|
||||
a.Logger.Debug().
|
||||
Str("authenticator", "public_share").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("successfully authenticated request")
|
||||
return r, true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var _ = Describe("Authenticating requests", Label("PublicShareAuthenticator"), func() {
|
||||
var authenticator Authenticator
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
authenticator = PublicShareAuthenticator{
|
||||
Logger: log.NewLogger(),
|
||||
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return mockGatewayClient{
|
||||
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
|
||||
if authType != "publicshares" {
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
}
|
||||
|
||||
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
|
||||
return "exampletoken", rpcv1beta1.Code_CODE_OK
|
||||
}
|
||||
|
||||
if clientID == "sharetoken" && clientSecret == "password|" {
|
||||
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
|
||||
}
|
||||
|
||||
return "", rpcv1beta1.Code_CODE_NOT_FOUND
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
When("the request contains correct data", func() {
|
||||
Context("using password authentication", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
|
||||
req.SetBasicAuth("public", "examples3cr3t")
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
|
||||
h := req2.Header
|
||||
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
|
||||
})
|
||||
})
|
||||
Context("using signature authentication", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken&signature=examplesignature&expiration=exampleexpiration", http.NoBody)
|
||||
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
|
||||
h := req2.Header
|
||||
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("the reguest is for the archiver", func() {
|
||||
Context("using a public-token", func() {
|
||||
It("should successfully authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver?public-token=sharetoken", http.NoBody)
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(true))
|
||||
Expect(req2).ToNot(BeNil())
|
||||
|
||||
h := req2.Header
|
||||
Expect(h.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
|
||||
})
|
||||
})
|
||||
Context("not using a public-token", func() {
|
||||
It("should fail to authenticate", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver", http.NoBody)
|
||||
req2, valid := authenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(false))
|
||||
Expect(req2).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockGatewayClient struct {
|
||||
gatewayv1beta1.GatewayAPIClient
|
||||
AuthenticateFunc func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code)
|
||||
}
|
||||
|
||||
func (c mockGatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.AuthenticateRequest, opts ...grpc.CallOption) (*gatewayv1beta1.AuthenticateResponse, error) {
|
||||
token, code := c.AuthenticateFunc(in.GetType(), in.GetClientId(), in.GetClientSecret())
|
||||
return &gatewayv1beta1.AuthenticateResponse{
|
||||
Status: &rpcv1beta1.Status{Code: code},
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
gofig "github.com/gookit/config/v2"
|
||||
"github.com/gookit/config/v2/yaml"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/unrolled/secure"
|
||||
"github.com/unrolled/secure/cspbuilder"
|
||||
yamlv3 "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadCSPConfig loads CSP header configuration from a yaml file.
|
||||
func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) {
|
||||
yamlContent, customYamlContent, err := loadCSPYaml(proxyCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadCSPConfig(yamlContent, customYamlContent)
|
||||
}
|
||||
|
||||
// LoadCSPConfig loads CSP header configuration from a yaml file.
|
||||
func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, error) {
|
||||
// substitute env vars and load to struct
|
||||
gofig.WithOptions(gofig.ParseEnv)
|
||||
gofig.AddDriver(yaml.Driver)
|
||||
|
||||
presetMap := map[string]any{}
|
||||
err := yamlv3.Unmarshal(presetYamlContent, &presetMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customMap := map[string]any{}
|
||||
err = yamlv3.Unmarshal(customYamlContent, &customMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mergedMap := deepMerge(presetMap, customMap)
|
||||
mergedYamlContent, err := yamlv3.Marshal(mergedMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = gofig.LoadSources("yaml", mergedYamlContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// read yaml
|
||||
cspConfig := config.CSP{}
|
||||
err = gofig.BindStruct("", &cspConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cspConfig, nil
|
||||
}
|
||||
|
||||
// deepMerge recursively merges map2 into map1.
|
||||
// - nested maps are merged recursively
|
||||
// - slices are concatenated, preserving order and avoiding duplicates
|
||||
// - scalar or type-mismatched values from map2 overwrite map1
|
||||
func deepMerge(map1, map2 map[string]any) map[string]any {
|
||||
if map1 == nil {
|
||||
out := make(map[string]any, len(map2))
|
||||
for k, v := range map2 {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for k, v2 := range map2 {
|
||||
if v1, ok := map1[k]; ok {
|
||||
// both maps -> recurse
|
||||
if m1, ok1 := v1.(map[string]any); ok1 {
|
||||
if m2, ok2 := v2.(map[string]any); ok2 {
|
||||
map1[k] = deepMerge(m1, m2)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// both slices -> merge unique
|
||||
if s1, ok1 := v1.([]any); ok1 {
|
||||
if s2, ok2 := v2.([]any); ok2 {
|
||||
merged := append([]any{}, s1...)
|
||||
for _, item := range s2 {
|
||||
if !sliceContains(merged, item) {
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
map1[k] = merged
|
||||
continue
|
||||
}
|
||||
// s1 is slice, v2 single -> append if missing
|
||||
if !sliceContains(s1, v2) {
|
||||
map1[k] = append(s1, v2)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// default: overwrite
|
||||
map1[k] = v2
|
||||
} else {
|
||||
// new key -> just set
|
||||
map1[k] = v2
|
||||
}
|
||||
}
|
||||
|
||||
return map1
|
||||
}
|
||||
|
||||
func sliceContains(slice []any, val any) bool {
|
||||
for _, v := range slice {
|
||||
if reflect.DeepEqual(v, val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadCSPYaml(proxyCfg *config.Config) ([]byte, []byte, error) {
|
||||
if proxyCfg.CSPConfigFileOverrideLocation != "" {
|
||||
overrideCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileOverrideLocation)
|
||||
return overrideCSPYaml, []byte{}, err
|
||||
}
|
||||
if proxyCfg.CSPConfigFileLocation == "" {
|
||||
return []byte(config.DefaultCSPConfig), nil, nil
|
||||
}
|
||||
customCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileLocation)
|
||||
return []byte(config.DefaultCSPConfig), customCSPYaml, err
|
||||
}
|
||||
|
||||
// Security is a middleware to apply security relevant http headers like CSP.
|
||||
func Security(cspConfig *config.CSP) func(h http.Handler) http.Handler {
|
||||
cspBuilder := cspbuilder.Builder{
|
||||
Directives: cspConfig.Directives,
|
||||
}
|
||||
|
||||
secureMiddleware := secure.New(secure.Options{
|
||||
BrowserXssFilter: true,
|
||||
ContentSecurityPolicy: cspBuilder.MustBuild(),
|
||||
ContentTypeNosniff: true,
|
||||
CustomFrameOptionsValue: "SAMEORIGIN",
|
||||
FrameDeny: true,
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
STSSeconds: 315360000,
|
||||
STSPreload: true,
|
||||
PermittedCrossDomainPolicies: "none",
|
||||
RobotTag: "none",
|
||||
})
|
||||
return func(next http.Handler) http.Handler {
|
||||
return secureMiddleware.Handler(next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
"gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
func TestLoadCSPConfig(t *testing.T) {
|
||||
// setup test env
|
||||
presetYaml := `
|
||||
directives:
|
||||
frame-src:
|
||||
- '''self'''
|
||||
- 'https://embed.diagrams.net/'
|
||||
- 'https://${ONLYOFFICE_DOMAIN|onlyoffice.qsfera.test}/'
|
||||
- 'https://${COLLABORA_DOMAIN|collabora.qsfera.test}/'
|
||||
`
|
||||
|
||||
customYaml := `
|
||||
directives:
|
||||
img-src:
|
||||
- '''self'''
|
||||
- 'data:'
|
||||
frame-src:
|
||||
- 'https://some.custom.domain/'
|
||||
`
|
||||
config, err := loadCSPConfig([]byte(presetYaml), []byte(customYaml))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "'self'"))
|
||||
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://embed.diagrams.net/"))
|
||||
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://onlyoffice.qsfera.test/"))
|
||||
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://collabora.qsfera.test/"))
|
||||
|
||||
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "'self'"))
|
||||
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "data:"))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// SelectorCookie provides a middleware which
|
||||
func SelectorCookie(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
policySelector := options.PolicySelector
|
||||
tracer := getTraceProvider(options).Tracer("proxy.middleware.selector_cookie")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &selectorCookie{
|
||||
next: next,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
policySelector: policySelector,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type selectorCookie struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
policySelector config.PolicySelector
|
||||
}
|
||||
|
||||
func (m selectorCookie) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
|
||||
req = req.WithContext(ctx)
|
||||
defer span.End()
|
||||
if m.policySelector.Regex == nil && m.policySelector.Claims == nil {
|
||||
// only set selector cookie for regex and claim selectors
|
||||
span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
selectorCookieName := ""
|
||||
if m.policySelector.Regex != nil {
|
||||
selectorCookieName = m.policySelector.Regex.SelectorCookieName
|
||||
} else if m.policySelector.Claims != nil {
|
||||
selectorCookieName = m.policySelector.Claims.SelectorCookieName
|
||||
}
|
||||
|
||||
// update cookie
|
||||
if oidc.FromContext(req.Context()) != nil {
|
||||
|
||||
selectorFunc, err := policy.LoadSelector(&m.policySelector)
|
||||
if err != nil {
|
||||
m.logger.Err(err)
|
||||
}
|
||||
|
||||
selector, err := selectorFunc(req)
|
||||
if err != nil {
|
||||
m.logger.Err(err)
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: selectorCookieName,
|
||||
Value: selector,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
defer span.End()
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/qsfera/server/services/proxy/pkg/userroles"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
const (
|
||||
_paramOCSignature = "OC-Signature"
|
||||
_paramOCCredential = "OC-Credential" // #nosec G101
|
||||
_paramOCDate = "OC-Date"
|
||||
_paramOCExpires = "OC-Expires"
|
||||
_paramOCVerb = "OC-Verb"
|
||||
_paramOCAlgo = "OC-Algo"
|
||||
_paramOCJWTSig = "oc-jwt-sig"
|
||||
)
|
||||
|
||||
var (
|
||||
_requiredParams = [...]string{
|
||||
_paramOCSignature,
|
||||
_paramOCCredential,
|
||||
_paramOCDate,
|
||||
_paramOCExpires,
|
||||
_paramOCVerb,
|
||||
}
|
||||
)
|
||||
|
||||
// SignedURLAuthenticator is the authenticator responsible for authenticating signed URL requests.
|
||||
type SignedURLAuthenticator struct {
|
||||
Logger log.Logger
|
||||
PreSignedURLConfig config.PreSignedURL
|
||||
UserProvider backend.UserBackend
|
||||
UserRoleAssigner userroles.UserRoleAssigner
|
||||
Store microstore.Store
|
||||
Now func() time.Time
|
||||
URLVerifier signedurl.Verifier
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) shouldServeLegacy(req *http.Request) bool {
|
||||
if !m.PreSignedURLConfig.Enabled {
|
||||
return false
|
||||
}
|
||||
return req.URL.Query().Get(_paramOCSignature) != ""
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) shouldServe(req *http.Request) bool {
|
||||
if m.URLVerifier == nil {
|
||||
return false
|
||||
}
|
||||
return req.URL.Query().Get(_paramOCJWTSig) != ""
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) validate(req *http.Request) (err error) {
|
||||
query := req.URL.Query()
|
||||
|
||||
if err := m.allRequiredParametersArePresent(query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.requestMethodMatches(req.Method, query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.requestMethodIsAllowed(req.Method); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = m.urlIsExpired(query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.signatureIsValid(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) allRequiredParametersArePresent(query url.Values) (err error) {
|
||||
// check if required query parameters exist in given request query parameters
|
||||
// OC-Signature - the computed signature - server will verify the request upon this REQUIRED
|
||||
// OC-Credential - defines the user scope (shall we use the qsfera user id here - this might leak internal data ....) REQUIRED
|
||||
// OC-Date - defined the date the url was signed (ISO 8601 UTC) REQUIRED
|
||||
// OC-Expires - defines the expiry interval in seconds (between 1 and 604800 = 7 days) REQUIRED
|
||||
// TODO OC-Verb - defines for which http verb the request is valid - defaults to GET OPTIONAL
|
||||
for _, p := range _requiredParams {
|
||||
if query.Get(p) == "" {
|
||||
return fmt.Errorf("required %s parameter not found", p)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) requestMethodMatches(meth string, query url.Values) (err error) {
|
||||
// check if given url query parameter OC-Verb matches given request method
|
||||
if !strings.EqualFold(meth, query.Get(_paramOCVerb)) {
|
||||
return errors.New("required OC-Verb parameter did not match request method")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) requestMethodIsAllowed(meth string) (err error) {
|
||||
// check if given request method is allowed
|
||||
methodIsAllowed := false
|
||||
for _, am := range m.PreSignedURLConfig.AllowedHTTPMethods {
|
||||
if strings.EqualFold(meth, am) {
|
||||
methodIsAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !methodIsAllowed {
|
||||
return errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) urlIsExpired(query url.Values) (err error) {
|
||||
// check if url is expired by checking if given date (OC-Date) + expires in seconds (OC-Expires) is after now
|
||||
validFrom, err := time.Parse(time.RFC3339, query.Get(_paramOCDate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestExpiry, err := time.ParseDuration(query.Get(_paramOCExpires) + "s")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validTo := validFrom.Add(requestExpiry)
|
||||
if !(m.Now().Before(validTo)) {
|
||||
return errors.New("URL is expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) signatureIsValid(req *http.Request) (err error) {
|
||||
c := revactx.ContextMustGetUser(req.Context())
|
||||
signingKey, err := m.Store.Read(c.Id.OpaqueId)
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("could not retrieve signing key")
|
||||
return err
|
||||
}
|
||||
if len(signingKey[0].Value) == 0 {
|
||||
m.Logger.Error().Err(err).Msg("signing key empty")
|
||||
return err
|
||||
}
|
||||
u := m.buildUrlToSign(req)
|
||||
computedSignature := m.createSignature(u, signingKey[0].Value)
|
||||
signatureInURL := req.URL.Query().Get(_paramOCSignature)
|
||||
if computedSignature == signatureInURL {
|
||||
return nil
|
||||
}
|
||||
|
||||
// try a workaround for https://github.com/owncloud/ocis/issues/10180
|
||||
// Some reverse proxies might replace $ with %24 in the URL leading to a mismatch in the signature
|
||||
u = strings.Replace(u, "$", "%24", 1)
|
||||
computedSignature = m.createSignature(u, signingKey[0].Value)
|
||||
signatureInURL = req.URL.Query().Get(_paramOCSignature)
|
||||
if computedSignature == signatureInURL {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("signature mismatch: expected %s != actual %s", computedSignature, signatureInURL)
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) buildUrlToSign(req *http.Request) string {
|
||||
q := req.URL.Query()
|
||||
|
||||
// only params required for signing
|
||||
signParameters := make(url.Values)
|
||||
signParameters.Add(_paramOCCredential, q.Get(_paramOCCredential))
|
||||
signParameters.Add(_paramOCDate, q.Get(_paramOCDate))
|
||||
signParameters.Add(_paramOCExpires, q.Get(_paramOCExpires))
|
||||
signParameters.Add(_paramOCVerb, q.Get(_paramOCVerb))
|
||||
|
||||
// remaining query params
|
||||
q.Del(_paramOCAlgo)
|
||||
q.Del(_paramOCCredential)
|
||||
q.Del(_paramOCDate)
|
||||
q.Del(_paramOCExpires)
|
||||
q.Del(_paramOCSignature)
|
||||
q.Del(_paramOCVerb)
|
||||
|
||||
url := *req.URL
|
||||
if len(q) == 0 {
|
||||
url.RawQuery = signParameters.Encode()
|
||||
} else {
|
||||
url.RawQuery = strings.Join([]string{q.Encode(), signParameters.Encode()}, "&")
|
||||
}
|
||||
u := url.String()
|
||||
if !url.IsAbs() {
|
||||
u = "https://" + req.Host + u // TODO where do we get the scheme
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) createSignature(url string, signingKey []byte) string {
|
||||
// the oc10 signature check: $hash = \hash_pbkdf2("sha512", $url, $signingKey, 10000, 64, false);
|
||||
// - sets the length of the output string to 64
|
||||
// - sets raw output to false -> if raw_output is FALSE length corresponds to twice the byte-length of the derived key (as every byte of the key is returned as two hexits).
|
||||
// TODO change to length 128 in oc10?
|
||||
// fo golangs pbkdf2.Key we need to use 32 because it will be encoded into 64 hexits later
|
||||
hash := pbkdf2.Key([]byte(url), signingKey, 10000, 32, sha512.New)
|
||||
return hex.EncodeToString(hash)
|
||||
}
|
||||
|
||||
// Authenticate implements the authenticator interface to authenticate requests via signed URL auth.
|
||||
func (m SignedURLAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
|
||||
switch {
|
||||
case m.shouldServeLegacy(r):
|
||||
return m.authenticateLegacy(r)
|
||||
case m.shouldServe(r):
|
||||
return m.authenticate(r)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) authenticate(r *http.Request) (*http.Request, bool) {
|
||||
u := r.URL.String()
|
||||
if !r.URL.IsAbs() {
|
||||
u = "https://" + r.Host + u
|
||||
}
|
||||
|
||||
userid, err := m.URLVerifier.Verify(u)
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url_jwt").
|
||||
Str("path", r.URL.Path).
|
||||
Str("url", u).
|
||||
Msg("Could not verify JWT signature")
|
||||
return nil, false
|
||||
}
|
||||
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "userid", userid)
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url_jwt").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Could not get user by claim")
|
||||
return nil, false
|
||||
}
|
||||
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Could not get user by claim")
|
||||
return nil, false
|
||||
}
|
||||
ctx := revactx.ContextSetUser(r.Context(), user)
|
||||
r = r.WithContext(ctx)
|
||||
m.Logger.Debug().
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("successfully authenticated request")
|
||||
return r, true
|
||||
}
|
||||
|
||||
// authenticateLegacy is a helper function to authenticate requests that use the legacy
|
||||
// client side signed URLs
|
||||
func (m SignedURLAuthenticator) authenticateLegacy(r *http.Request) (*http.Request, bool) {
|
||||
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "username", r.URL.Query().Get(_paramOCCredential))
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Could not get user by claim")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
|
||||
if err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Could not get user by claim")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
ctx := revactx.ContextSetUser(r.Context(), user)
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
if err := m.validate(r); err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Str("url", r.URL.String()).
|
||||
Msg("Could not get user by claim")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
m.Logger.Debug().
|
||||
Str("authenticator", "signed_url").
|
||||
Str("path", r.URL.Path).
|
||||
Msg("successfully authenticated request")
|
||||
return r, true
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
func TestSignedURLAuthLegacy_shouldServe(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
tests := []struct {
|
||||
url string
|
||||
enabled bool
|
||||
expected bool
|
||||
}{
|
||||
{"https://example.com/example.jpg", true, false},
|
||||
{"https://example.com/example.jpg?OC-Signature=something", true, true},
|
||||
{"https://example.com/example.jpg", false, false},
|
||||
{"https://example.com/example.jpg?OC-Signature=something", false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua.PreSignedURLConfig.Enabled = tt.enabled
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
result := pua.shouldServeLegacy(r)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_shouldServe(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
secret string
|
||||
enabled bool
|
||||
expected bool
|
||||
}{
|
||||
{"https://example.com/example.jpg", "", true, false},
|
||||
{"https://example.com/example.jpg", "", false, false},
|
||||
{"https://example.com/example.jpg?oc-jwt-sig=something1", "secret", true, true},
|
||||
{"https://example.com/example.jpg?oc-jwt-sig=something2", "", true, false},
|
||||
{"https://example.com/example.jpg?oc-jwt-sig=something3", "secret", false, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua := SignedURLAuthenticator{}
|
||||
pua.PreSignedURLConfig.Enabled = tt.enabled
|
||||
if tt.secret != "" {
|
||||
signURLVerifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(tt.secret))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signed URL verifier: %v", err)
|
||||
}
|
||||
pua.URLVerifier = signURLVerifier
|
||||
}
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
result := pua.shouldServe(r)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_allRequiredParametersPresent(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
baseURL := "https://example.com/example.jpg?"
|
||||
tests := []struct {
|
||||
params string
|
||||
errorMessage string
|
||||
}{
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", ""},
|
||||
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Signature parameter not found"},
|
||||
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Credential parameter not found"},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", "required OC-Date parameter not found"},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", "required OC-Expires parameter not found"},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", "required OC-Verb parameter not found"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", baseURL+tt.params, nil)
|
||||
err := pua.allRequiredParametersArePresent(r.URL.Query())
|
||||
if tt.errorMessage != "" {
|
||||
assert.EqualError(t, err, tt.errorMessage, tt.params)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodMatches(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
tests := []struct {
|
||||
method string
|
||||
url string
|
||||
errorMessage string
|
||||
}{
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=GET", ""},
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=get", ""},
|
||||
{"POST", "https://example.com/example.jpg?OC-Verb=GET", "required OC-Verb parameter did not match request method"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest(tt.method, tt.url, nil)
|
||||
err := pua.requestMethodMatches(r.Method, r.URL.Query())
|
||||
if tt.errorMessage != "" {
|
||||
assert.EqualError(t, err, tt.errorMessage, tt.url)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodIsAllowed(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
tests := []struct {
|
||||
method string
|
||||
allowed []string
|
||||
errorMessage string
|
||||
}{
|
||||
{"GET", []string{}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
|
||||
{"GET", []string{"POST"}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
|
||||
{"GET", []string{"GET"}, ""},
|
||||
{"GET", []string{"get"}, ""},
|
||||
{"GET", []string{"POST", "GET"}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua.PreSignedURLConfig.AllowedHTTPMethods = tt.allowed
|
||||
err := pua.requestMethodIsAllowed(tt.method)
|
||||
if tt.errorMessage != "" {
|
||||
assert.EqualError(t, err, tt.errorMessage)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_urlIsExpired(t *testing.T) {
|
||||
nowFunc := func() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
|
||||
return t
|
||||
}
|
||||
pua := SignedURLAuthenticator{
|
||||
Now: nowFunc,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
url string
|
||||
errorMessage string
|
||||
}{
|
||||
// a valid signed url
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=61", ""},
|
||||
// invalid expiry
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "time: invalid duration \"invalids\""},
|
||||
// wrong date format on OC-Date
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02TTT12:29:00.000Z&OC-Expires=5", "parsing time \"2020-02-02TTT12:29:00.000Z\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"TT12:29:00.000Z\" as \"15\""},
|
||||
// expired - 12:29:00 + 59s < 12:30
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=59", "URL is expired"},
|
||||
// expired - basically url was created yesterday
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-01T12:29:00.000Z&OC-Expires=59", "URL is expired"},
|
||||
// future OC-Date - also valid now
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-03T12:29:00.000Z&OC-Expires=59", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
err := pua.urlIsExpired(r.URL.Query())
|
||||
if tt.errorMessage != "" {
|
||||
assert.EqualError(t, err, tt.errorMessage, tt.url)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_createSignature(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
|
||||
s := pua.createSignature("something", []byte("somerandomkey"))
|
||||
|
||||
if s != expected {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_validate(t *testing.T) {
|
||||
nowFunc := func() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
|
||||
return t
|
||||
}
|
||||
cfg := config.PreSignedURL{
|
||||
AllowedHTTPMethods: []string{"get"},
|
||||
Enabled: true,
|
||||
}
|
||||
pua := SignedURLAuthenticator{
|
||||
PreSignedURLConfig: cfg,
|
||||
Store: store.NewMemoryStore(),
|
||||
Now: nowFunc,
|
||||
}
|
||||
|
||||
pua.Store.Write(&store.Record{
|
||||
Key: "useri",
|
||||
Value: []byte("1234567890"),
|
||||
Metadata: nil,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
now string
|
||||
url string
|
||||
errorMessage string
|
||||
}{
|
||||
{"2020-02-02T12:30:00.000Z", "http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "required OC-Signature parameter not found"},
|
||||
{"2020-02-02T12:30:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", "URL is expired"},
|
||||
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b", "signature mismatch: expected f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6 != actual f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b"},
|
||||
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
|
||||
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
|
||||
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Algo=PBKDF2%2F10000-SHA512&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
|
||||
{"2024-02-07T12:03:11.966Z", "http://localhost:33001/try?id=1&id=2&OC-Credential=user&OC-Date=2024-02-07T12%3A03%3A11.966Z&OC-Expires=2&OC-Verb=GET&OC-Algo=PBKDF2%2F10000-SHA512&OC-Signature=86e21a1efbf0be989a206109cfedf70a22f338dc8995e849ce002032bc6741c5", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
u := userpb.User{
|
||||
Id: &userpb.UserId{OpaqueId: "useri"},
|
||||
DisplayName: "Test User",
|
||||
}
|
||||
ctx := revactx.ContextSetUser(context.Background(), &u)
|
||||
|
||||
pua.Now = func() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, tt.now)
|
||||
return t
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("", tt.url, nil).WithContext(ctx)
|
||||
err := pua.validate(r)
|
||||
if tt.errorMessage == "" {
|
||||
assert.Nil(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tt.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Tracer provides a middleware to start traces
|
||||
func Tracer(tp trace.TracerProvider) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &tracer{
|
||||
next: next,
|
||||
traceProvider: tp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type tracer struct {
|
||||
next http.Handler
|
||||
traceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
func (m tracer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.SetAttributes(
|
||||
attribute.KeyValue{
|
||||
Key: "x-request-id",
|
||||
Value: attribute.StringValue(chimiddleware.GetReqID(r.Context())),
|
||||
})
|
||||
|
||||
m.next.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMultipleSelectors in case there is more then one selector configured.
|
||||
ErrMultipleSelectors = fmt.Errorf("only one type of policy-selector (static, migration, claim or regex) can be configured")
|
||||
// ErrSelectorConfigIncomplete if policy_selector conf is missing
|
||||
ErrSelectorConfigIncomplete = fmt.Errorf("missing either \"static\", \"migration\", \"claim\" or \"regex\" configuration in policy_selector config ")
|
||||
// ErrUnexpectedConfigError unexpected config error
|
||||
ErrUnexpectedConfigError = fmt.Errorf("could not initialize policy-selector for given config")
|
||||
)
|
||||
|
||||
const (
|
||||
SelectorCookieName = "qsfera-selector"
|
||||
)
|
||||
|
||||
// Selector is a function which selects a proxy-policy based on the request.
|
||||
//
|
||||
// A policy is a random name which identifies a set of proxy-routes:
|
||||
//
|
||||
// {
|
||||
// "policies": [
|
||||
// {
|
||||
// "name": "us-east-1",
|
||||
// "routes": [
|
||||
// {
|
||||
// "endpoint": "/",
|
||||
// "backend": "https://backend.us.example.com:8080/app"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// "name": "eu-ams-1",
|
||||
// "routes": [
|
||||
// {
|
||||
// "endpoint": "/",
|
||||
// "backend": "https://backend.eu.example.com:8080/app"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
type Selector func(r *http.Request) (string, error)
|
||||
|
||||
// LoadSelector constructs a specific policy-selector from a given configuration
|
||||
func LoadSelector(cfg *config.PolicySelector) (Selector, error) {
|
||||
selCount := 0
|
||||
|
||||
if cfg.Static != nil {
|
||||
selCount++
|
||||
}
|
||||
if cfg.Claims != nil {
|
||||
selCount++
|
||||
}
|
||||
if cfg.Regex != nil {
|
||||
selCount++
|
||||
}
|
||||
if selCount > 1 {
|
||||
return nil, ErrMultipleSelectors
|
||||
}
|
||||
|
||||
if cfg.Static == nil && cfg.Claims == nil && cfg.Regex == nil {
|
||||
return nil, ErrSelectorConfigIncomplete
|
||||
}
|
||||
|
||||
if cfg.Static != nil {
|
||||
return NewStaticSelector(cfg.Static), nil
|
||||
}
|
||||
|
||||
if cfg.Claims != nil {
|
||||
if cfg.Claims.SelectorCookieName == "" {
|
||||
cfg.Claims.SelectorCookieName = SelectorCookieName
|
||||
}
|
||||
return NewClaimsSelector(cfg.Claims), nil
|
||||
}
|
||||
|
||||
if cfg.Regex != nil {
|
||||
if cfg.Regex.SelectorCookieName == "" {
|
||||
cfg.Regex.SelectorCookieName = SelectorCookieName
|
||||
}
|
||||
return NewRegexSelector(cfg.Regex), nil
|
||||
}
|
||||
|
||||
return nil, ErrUnexpectedConfigError
|
||||
}
|
||||
|
||||
// NewStaticSelector returns a selector which uses a pre-configured policy.
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// "policy_selector": {
|
||||
// "static": {"policy" : "qsfera"}
|
||||
// },
|
||||
func NewStaticSelector(cfg *config.StaticSelectorConf) Selector {
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
return cfg.Policy, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewClaimsSelector selects the policy based on the "qsfera.routing.policy" claim
|
||||
// The policy for corner cases is configurable:
|
||||
//
|
||||
// "policy_selector": {
|
||||
// "migration": {
|
||||
// "default_policy" : "qsfera",
|
||||
// "unauthenticated_policy": "oc10"
|
||||
// }
|
||||
// },
|
||||
//
|
||||
// This selector can be used in migration-scenarios or to set up sharded deployments
|
||||
func NewClaimsSelector(cfg *config.ClaimsSelectorConf) Selector {
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
|
||||
selectorCookie := func(r *http.Request) string {
|
||||
selectorCookie, err := r.Cookie(cfg.SelectorCookieName)
|
||||
if err == nil {
|
||||
// TODO check we know the routing policy?
|
||||
return selectorCookie.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// first, try to route by selector
|
||||
if claims := oidc.FromContext(r.Context()); claims != nil {
|
||||
if p, ok := claims[oidc.QsferaRoutingPolicy].(string); ok && p != "" {
|
||||
// TODO check we know the routing policy?
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// basic auth requests don't have a routing claim, so check for the cookie
|
||||
if s := selectorCookie(r); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return cfg.DefaultPolicy, nil
|
||||
}
|
||||
|
||||
// use cookie if provided
|
||||
if s := selectorCookie(r); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return cfg.UnauthenticatedPolicy, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewRegexSelector selects the policy based on a user property
|
||||
// The policy for each case is configurable:
|
||||
//
|
||||
// "policy_selector": {
|
||||
// "regex": {
|
||||
// "matches_policies": [
|
||||
// {"priority": 10, "property": "mail", "match": "mary@example.org", "policy": "qsfera"},
|
||||
// {"priority": 20, "property": "mail", "match": "[^@]+@example.org", "policy": "oc10"},
|
||||
// {"priority": 30, "property": "username", "match": "(dennis|feynman)", "policy": "qsfera"},
|
||||
// {"priority": 40, "property": "username", "match": ".+", "policy": "oc10"},
|
||||
// {"priority": 50, "property": "id", "match": "b1f74ec4-dd7e-11ef-a543-03775734d0f7", "policy": "qsfera"},
|
||||
// {"priority": 60, "property": "id", "match": "056fc874-dd7f-11ef-ba84-af6fca4b7289", "policy": "oc10"}
|
||||
// ],
|
||||
// "unauthenticated_policy": "oc10"
|
||||
// }
|
||||
// },
|
||||
//
|
||||
// This selector can be used in migration-scenarios or to set up sharded deployments
|
||||
func NewRegexSelector(cfg *config.RegexSelectorConf) Selector {
|
||||
regexRules := []*regexRule{}
|
||||
sort.Slice(cfg.MatchesPolicies, func(i, j int) bool {
|
||||
return cfg.MatchesPolicies[i].Priority < cfg.MatchesPolicies[j].Priority
|
||||
})
|
||||
for i := range cfg.MatchesPolicies {
|
||||
regexRules = append(regexRules, ®exRule{
|
||||
property: cfg.MatchesPolicies[i].Property,
|
||||
rule: regexp.MustCompile(cfg.MatchesPolicies[i].Match),
|
||||
policy: cfg.MatchesPolicies[i].Policy,
|
||||
})
|
||||
}
|
||||
return func(r *http.Request) (s string, err error) {
|
||||
// use cookie first if provided
|
||||
selectorCookie, err := r.Cookie(cfg.SelectorCookieName)
|
||||
if err == nil {
|
||||
return selectorCookie.Value, nil
|
||||
}
|
||||
|
||||
// if no cookie is present, try to route by selector
|
||||
if u, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
for i := range regexRules {
|
||||
switch regexRules[i].property {
|
||||
case "mail":
|
||||
if regexRules[i].rule.MatchString(u.Mail) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
case "username":
|
||||
if regexRules[i].rule.MatchString(u.Username) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
case "id":
|
||||
if u.Id != nil && regexRules[i].rule.MatchString(u.Id.OpaqueId) {
|
||||
return regexRules[i].policy, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg.DefaultPolicy, nil
|
||||
}
|
||||
|
||||
return cfg.UnauthenticatedPolicy, nil
|
||||
}
|
||||
}
|
||||
|
||||
type regexRule struct {
|
||||
property string
|
||||
rule *regexp.Regexp
|
||||
policy string
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
)
|
||||
|
||||
func TestLoadSelector(t *testing.T) {
|
||||
type test struct {
|
||||
cfg *config.PolicySelector
|
||||
expectedErr error
|
||||
}
|
||||
sCfg := &config.StaticSelectorConf{Policy: "reva"}
|
||||
ccfg := &config.ClaimsSelectorConf{}
|
||||
rcfg := &config.RegexSelectorConf{}
|
||||
|
||||
table := []test{
|
||||
{cfg: &config.PolicySelector{Static: sCfg, Claims: ccfg, Regex: rcfg}, expectedErr: ErrMultipleSelectors},
|
||||
{cfg: &config.PolicySelector{}, expectedErr: ErrSelectorConfigIncomplete},
|
||||
{cfg: &config.PolicySelector{Static: sCfg}, expectedErr: nil},
|
||||
{cfg: &config.PolicySelector{Claims: ccfg}, expectedErr: nil},
|
||||
{cfg: &config.PolicySelector{Regex: rcfg}, expectedErr: nil},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
_, err := LoadSelector(test.cfg)
|
||||
if err != test.expectedErr {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticSelector(t *testing.T) {
|
||||
sel := NewStaticSelector(&config.StaticSelectorConf{Policy: "qsfera"})
|
||||
req := httptest.NewRequest("GET", "https://example.org/foo", nil)
|
||||
want := "qsfera"
|
||||
got, err := sel(req)
|
||||
if got != want {
|
||||
t.Errorf("Expected policy %v got %v", want, got)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
|
||||
sel = NewStaticSelector(&config.StaticSelectorConf{Policy: "foo"})
|
||||
|
||||
want = "foo"
|
||||
got, err = sel(req)
|
||||
if got != want {
|
||||
t.Errorf("Expected policy %v got %v", want, got)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
Name string
|
||||
Context context.Context
|
||||
Cookie *http.Cookie
|
||||
Expected string
|
||||
}
|
||||
|
||||
func TestClaimsSelector(t *testing.T) {
|
||||
sel := NewClaimsSelector(&config.ClaimsSelectorConf{
|
||||
DefaultPolicy: "default",
|
||||
UnauthenticatedPolicy: "unauthenticated",
|
||||
SelectorCookieName: SelectorCookieName,
|
||||
})
|
||||
|
||||
var tests = []testCase{
|
||||
{"unauthenticated", context.Background(), nil, "unauthenticated"},
|
||||
{"default", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: ""}), nil, "default"},
|
||||
{"claim-value", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: "qsfera.routing.policy-value"}), nil, "qsfera.routing.policy-value"},
|
||||
{"cookie-only", context.Background(), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "cookie"},
|
||||
{"claim-can-override-cookie", oidc.NewContext(context.Background(), map[string]any{oidc.QsferaRoutingPolicy: "qsfera.routing.policy-value"}), &http.Cookie{Name: SelectorCookieName, Value: "cookie"}, "qsfera.routing.policy-value"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
r := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
if tc.Cookie != nil {
|
||||
r.AddCookie(tc.Cookie)
|
||||
}
|
||||
nr := r.WithContext(tc.Context)
|
||||
got, err := sel(nr)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.Expected {
|
||||
t.Errorf("Expected Policy %v got %v", tc.Expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexSelector(t *testing.T) {
|
||||
sel := NewRegexSelector(&config.RegexSelectorConf{
|
||||
DefaultPolicy: "default",
|
||||
MatchesPolicies: []config.RegexRuleConf{
|
||||
{Priority: 10, Property: "mail", Match: "mary@example.org", Policy: "qsfera"},
|
||||
{Priority: 20, Property: "mail", Match: "[^@]+@example.org", Policy: "oc10"},
|
||||
{Priority: 30, Property: "username", Match: "(alan|feynman)", Policy: "qsfera"},
|
||||
{Priority: 40, Property: "username", Match: ".+", Policy: "oc10"},
|
||||
{Priority: 50, Property: "id", Match: "b1f74ec4-dd7e-11ef-a543-03775734d0f7", Policy: "qsfera"},
|
||||
{Priority: 60, Property: "id", Match: "056fc874-dd7f-11ef-ba84-af6fca4b7289", Policy: "oc10"},
|
||||
},
|
||||
UnauthenticatedPolicy: "unauthenticated",
|
||||
})
|
||||
|
||||
var tests = []testCase{
|
||||
{"unauthenticated", context.Background(), nil, "unauthenticated"},
|
||||
{"default", revactx.ContextSetUser(context.Background(), &userv1beta1.User{}), nil, "default"},
|
||||
{"mail-qsfera", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "mary@example.org"}), nil, "qsfera"},
|
||||
{"mail-oc10", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Mail: "alan@example.org"}), nil, "oc10"},
|
||||
{"username-alan", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "alan"}), nil, "qsfera"},
|
||||
{"username-feynman", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "feynman"}), nil, "qsfera"},
|
||||
{"username-mary", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Username: "mary"}), nil, "oc10"},
|
||||
{"id-nil", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{}}), nil, "default"},
|
||||
{"id-1", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{OpaqueId: "b1f74ec4-dd7e-11ef-a543-03775734d0f7"}}), nil, "qsfera"},
|
||||
{"id-2", revactx.ContextSetUser(context.Background(), &userv1beta1.User{Id: &userv1beta1.UserId{OpaqueId: "056fc874-dd7f-11ef-ba84-af6fca4b7289"}}), nil, "oc10"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
nr := r.WithContext(tc.Context)
|
||||
got, err := sel(nr)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.Expected {
|
||||
t.Errorf("Expected Policy %v got %v", tc.Expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
stdlog "log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// MultiHostReverseProxy extends "httputil" to support multiple hosts with different policies
|
||||
type MultiHostReverseProxy struct {
|
||||
httputil.ReverseProxy
|
||||
// Directors holds policy route type method endpoint Director
|
||||
Directors map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)
|
||||
PolicySelector policy.Selector
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewMultiHostReverseProxy creates a new MultiHostReverseProxy
|
||||
func NewMultiHostReverseProxy(opts ...Option) (*MultiHostReverseProxy, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
rp := &MultiHostReverseProxy{
|
||||
ReverseProxy: httputil.ReverseProxy{
|
||||
ErrorLog: stdlog.New(options.Logger, "", 0),
|
||||
},
|
||||
Directors: make(map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)),
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
}
|
||||
|
||||
rp.Rewrite = func(r *httputil.ProxyRequest) {
|
||||
ri := router.ContextRoutingInfo(r.In.Context())
|
||||
ri.Rewrite()(r)
|
||||
}
|
||||
|
||||
rp.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
|
||||
reqLogger := zerolog.Ctx(req.Context())
|
||||
if ev := reqLogger.Error(); ev.Enabled() {
|
||||
ev.Err(err).Msg("error happened in MultiHostReverseProxy")
|
||||
} else {
|
||||
rp.logger.Err(err).Msg("error happened in MultiHostReverseProxy")
|
||||
}
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
|
||||
tlsConf := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: options.Config.InsecureBackends, //nolint:gosec
|
||||
}
|
||||
if options.Config.BackendHTTPSCACert != "" {
|
||||
certs := x509.NewCertPool()
|
||||
pemData, err := os.ReadFile(options.Config.BackendHTTPSCACert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !certs.AppendCertsFromPEM(pemData) {
|
||||
return nil, errors.New("Error initializing LDAP Backend. Adding CA cert failed")
|
||||
}
|
||||
tlsConf.RootCAs = certs
|
||||
}
|
||||
// equals http.DefaultTransport except TLSClientConfig
|
||||
rp.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: tlsConf,
|
||||
}
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func (p *MultiHostReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
p.ReverseProxy.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/router"
|
||||
"go-micro.dev/v4/selector"
|
||||
)
|
||||
|
||||
func TestProxyIntegration(t *testing.T) {
|
||||
var tests = []testCase{
|
||||
// Simple prefix route
|
||||
test("simple_prefix", withPolicy("qsfera", withRoutes{{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com"},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Complex prefix route, different method
|
||||
test("complex_prefix_post", withPolicy("qsfera", withRoutes{{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com/service1/"},
|
||||
})).withRequest("POST", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/service1/api"),
|
||||
|
||||
// Query route
|
||||
test("query_route", withPolicy("qsfera", withRoutes{{
|
||||
Type: config.QueryRoute,
|
||||
Endpoint: "/api?format=json",
|
||||
Backend: "http://backend/"},
|
||||
})).withRequest("GET", "https://example.com/api?format=json", nil).
|
||||
expectProxyTo("http://backend/api?format=json"),
|
||||
|
||||
// Regex route
|
||||
test("regex_route", withPolicy("qsfera", withRoutes{{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://backend/"},
|
||||
})).withRequest("POST", "https://example.com/user/1234", nil).
|
||||
expectProxyTo("http://backend/user/1234"),
|
||||
|
||||
// Multiple prefix routes 1
|
||||
test("multiple_prefix", withPolicy("qsfera", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/payment",
|
||||
Backend: "http://payment.example.com",
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/payment", nil).
|
||||
expectProxyTo("http://payment.example.com/payment"),
|
||||
|
||||
// Multiple prefix routes 2
|
||||
test("multiple_prefix", withPolicy("qsfera", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/payment",
|
||||
Backend: "http://payment.example.com",
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Mixed route types
|
||||
test("mixed_types", withPolicy("qsfera", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://users.example.com",
|
||||
ApacheVHost: false,
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/api", nil).
|
||||
expectProxyTo("http://api.example.com/api"),
|
||||
|
||||
// Mixed route types
|
||||
test("mixed_types", withPolicy("qsfera", withRoutes{
|
||||
{
|
||||
Type: config.PrefixRoute,
|
||||
Endpoint: "/api",
|
||||
Backend: "http://api.example.com",
|
||||
},
|
||||
{
|
||||
Type: config.RegexRoute,
|
||||
Endpoint: `\/user\/(\d+)`,
|
||||
Backend: "http://users.example.com",
|
||||
ApacheVHost: false,
|
||||
},
|
||||
})).withRequest("GET", "https://example.com/user/1234", nil).
|
||||
expectProxyTo("http://users.example.com/user/1234"),
|
||||
}
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
sel := selector.NewSelector(selector.Registry(reg))
|
||||
|
||||
for k := range tests {
|
||||
t.Run(tests[k].id, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tc := tests[k]
|
||||
|
||||
rt := router.Middleware(sel, nil, tc.conf, log.NewLogger())
|
||||
rp := newTestProxy(testConfig(tc.conf), func(req *http.Request) *http.Response {
|
||||
if got, want := req.URL.String(), tc.expect.String(); got != want {
|
||||
t.Errorf("Proxied url should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
if got, want := req.Method, tc.input.Method; got != want {
|
||||
t.Errorf("Proxied request method should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
if got, want := req.Proto, tc.input.Proto; got != want {
|
||||
t.Errorf("Proxied request proto should be %v got %v", want, got)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`OK`)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
rt(rp).ServeHTTP(rr, tc.input)
|
||||
rsp := rr.Result()
|
||||
|
||||
if rsp.StatusCode != 200 {
|
||||
t.Errorf("Expected status 200 from proxy-response got %v", rsp.StatusCode)
|
||||
}
|
||||
|
||||
resultBody, err := io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
t.Fatal("Error reading result body")
|
||||
}
|
||||
if err = rsp.Body.Close(); err != nil {
|
||||
t.Fatal("Error closing result body")
|
||||
}
|
||||
|
||||
bodyString := string(resultBody)
|
||||
if bodyString != `OK` {
|
||||
t.Errorf("Result body of proxied response should be OK, got %v", bodyString)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestProxy(cfg *config.Config, fn RoundTripFunc) *MultiHostReverseProxy {
|
||||
rp, _ := NewMultiHostReverseProxy(Config(cfg))
|
||||
rp.Transport = fn
|
||||
return rp
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) *http.Response
|
||||
|
||||
// RoundTrip .
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req), nil
|
||||
}
|
||||
|
||||
type withRoutes []config.Route
|
||||
|
||||
type testCase struct {
|
||||
id string
|
||||
input *http.Request
|
||||
expect *url.URL
|
||||
conf []config.Policy
|
||||
}
|
||||
|
||||
func test(id string, policies ...config.Policy) *testCase {
|
||||
tc := &testCase{
|
||||
id: id,
|
||||
}
|
||||
for k := range policies {
|
||||
tc.conf = append(tc.conf, policies[k])
|
||||
}
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
func withPolicy(name string, r withRoutes) config.Policy {
|
||||
return config.Policy{Name: name, Routes: r}
|
||||
}
|
||||
|
||||
func (tc *testCase) withRequest(method string, target string, body io.Reader) *testCase {
|
||||
tc.input = httptest.NewRequest(method, target, body)
|
||||
return tc
|
||||
}
|
||||
|
||||
func (tc *testCase) expectProxyTo(strURL string) testCase {
|
||||
pu, err := url.Parse(strURL)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Error parsing %v", strURL))
|
||||
}
|
||||
|
||||
tc.expect = pu
|
||||
return *tc
|
||||
}
|
||||
|
||||
func testConfig(policy []config.Policy) *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{},
|
||||
HTTP: config.HTTP{},
|
||||
Policies: policy,
|
||||
OIDC: config.OIDC{},
|
||||
PolicySelector: nil,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
|
||||
"go-micro.dev/v4/selector"
|
||||
)
|
||||
|
||||
type routingInfoCtxKey struct{}
|
||||
|
||||
var noInfo = RoutingInfo{}
|
||||
|
||||
// Middleware returns a HTTP middleware containing the router.
|
||||
func Middleware(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) func(http.Handler) http.Handler {
|
||||
router := New(serviceSelector, policySelectorCfg, policies, logger)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ri, ok := router.Route(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(SetRoutingInfo(r.Context(), ri)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new request router.
|
||||
// It initializes the routes before returning the router.
|
||||
func New(serviceSelector selector.Selector, policySelectorCfg *config.PolicySelector, policies []config.Policy, logger log.Logger) Router {
|
||||
if policySelectorCfg == nil {
|
||||
firstPolicy := policies[0].Name
|
||||
logger.Warn().Str("policy", firstPolicy).Msg("policy-selector not configured. Will always use first policy")
|
||||
policySelectorCfg = &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: firstPolicy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Interface("selector_config", policySelectorCfg).
|
||||
Msg("loading policy-selector")
|
||||
|
||||
policySelector, err := policy.LoadSelector(policySelectorCfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Could not load policy-selector")
|
||||
}
|
||||
|
||||
r := Router{
|
||||
logger: logger,
|
||||
rewriters: make(map[string]map[config.RouteType]map[string][]RoutingInfo),
|
||||
policySelector: policySelector,
|
||||
serviceSelector: serviceSelector,
|
||||
}
|
||||
for _, pol := range policies {
|
||||
for _, route := range pol.Routes {
|
||||
logger.Debug().Str("fwd: ", route.Endpoint)
|
||||
|
||||
if route.Backend == "" && route.Service == "" {
|
||||
logger.Fatal().Interface("route", route).Msg("neither Backend nor Service is set")
|
||||
}
|
||||
uri, err2 := url.Parse(route.Backend)
|
||||
if err2 != nil {
|
||||
logger.
|
||||
Fatal(). // fail early on misconfiguration
|
||||
Err(err2).
|
||||
Str("backend", route.Backend).
|
||||
Msg("malformed url")
|
||||
}
|
||||
|
||||
// here the backend is used as a uri
|
||||
r.addHost(pol.Name, uri, route)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RoutingInfo contains the proxy rewrite hook and some information about the route.
|
||||
type RoutingInfo struct {
|
||||
rewrite func(*httputil.ProxyRequest)
|
||||
endpoint string
|
||||
unprotected bool
|
||||
remoteUserHeader string
|
||||
skipXAccessToken bool
|
||||
}
|
||||
|
||||
// Rewrite returns the proxy rewrite hook.
|
||||
func (r RoutingInfo) Rewrite() func(*httputil.ProxyRequest) {
|
||||
return r.rewrite
|
||||
}
|
||||
|
||||
// IsRouteUnprotected returns true if the route doesn't need to be authenticated.
|
||||
func (r RoutingInfo) IsRouteUnprotected() bool {
|
||||
return r.unprotected
|
||||
}
|
||||
|
||||
// RemoteUserHeader returns the name of Header for setting the remote user value
|
||||
func (r RoutingInfo) RemoteUserHeader() string {
|
||||
return r.remoteUserHeader
|
||||
}
|
||||
|
||||
// SkipXAccessToken return true if the reva access token should not be added to the
|
||||
// outgoing request
|
||||
func (r RoutingInfo) SkipXAccessToken() bool {
|
||||
return r.skipXAccessToken
|
||||
}
|
||||
|
||||
// Router handles the routing of HTTP requests according to the given policies.
|
||||
type Router struct {
|
||||
logger log.Logger
|
||||
rewriters map[string]map[config.RouteType]map[string][]RoutingInfo
|
||||
policySelector policy.Selector
|
||||
serviceSelector selector.Selector
|
||||
}
|
||||
|
||||
func (rt Router) addHost(policy string, target *url.URL, route config.Route) {
|
||||
targetQuery := target.RawQuery
|
||||
if rt.rewriters[policy] == nil {
|
||||
rt.rewriters[policy] = make(map[config.RouteType]map[string][]RoutingInfo)
|
||||
}
|
||||
routeType := config.DefaultRouteType
|
||||
if route.Type != "" {
|
||||
routeType = route.Type
|
||||
}
|
||||
if rt.rewriters[policy][routeType] == nil {
|
||||
rt.rewriters[policy][routeType] = make(map[string][]RoutingInfo)
|
||||
}
|
||||
if rt.rewriters[policy][routeType][route.Method] == nil {
|
||||
rt.rewriters[policy][routeType][route.Method] = make([]RoutingInfo, 0)
|
||||
}
|
||||
|
||||
rt.rewriters[policy][routeType][route.Method] = append(rt.rewriters[policy][routeType][route.Method], RoutingInfo{
|
||||
endpoint: route.Endpoint,
|
||||
unprotected: route.Unprotected,
|
||||
remoteUserHeader: route.RemoteUserHeader,
|
||||
skipXAccessToken: route.SkipXAccessToken,
|
||||
rewrite: func(req *httputil.ProxyRequest) {
|
||||
if route.Service != "" {
|
||||
// select next node
|
||||
next, err := rt.serviceSelector.Select(route.Service)
|
||||
if err != nil {
|
||||
rt.logger.Error().Err(err).
|
||||
Str("service", route.Service).
|
||||
Msg("could not select service from the registry")
|
||||
return // TODO error? fallback to target.Host & Scheme?
|
||||
}
|
||||
node, err := next()
|
||||
if err != nil {
|
||||
rt.logger.Error().Err(err).
|
||||
Str("service", route.Service).
|
||||
Msg("could not select next node")
|
||||
return // TODO error? fallback to target.Host & Scheme?
|
||||
}
|
||||
req.Out.URL.Host = node.Address
|
||||
req.Out.URL.Scheme = node.Metadata["protocol"] // TODO check property exists?
|
||||
if node.Metadata["use_tls"] == "true" {
|
||||
req.Out.URL.Scheme = "https"
|
||||
}
|
||||
} else {
|
||||
req.Out.URL.Host = target.Host
|
||||
req.Out.URL.Scheme = target.Scheme
|
||||
}
|
||||
|
||||
// Apache deployments host addresses need to match on req.Out.Host and req.Out.URL.Host
|
||||
// see https://stackoverflow.com/questions/34745654/golang-reverseproxy-with-apache2-sni-hostname-error
|
||||
if route.ApacheVHost {
|
||||
req.Out.Host = target.Host
|
||||
}
|
||||
|
||||
for k, v := range route.AdditionalHeaders {
|
||||
req.Out.Header.Set(k, v)
|
||||
}
|
||||
|
||||
req.Out.URL.Path = singleJoiningSlash(target.Path, req.Out.URL.Path)
|
||||
if targetQuery == "" || req.Out.URL.RawQuery == "" {
|
||||
req.Out.URL.RawQuery = targetQuery + req.Out.URL.RawQuery
|
||||
} else {
|
||||
req.Out.URL.RawQuery = targetQuery + "&" + req.Out.URL.RawQuery
|
||||
}
|
||||
if _, ok := req.Out.Header["User-Agent"]; !ok {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Out.Header.Set("User-Agent", "")
|
||||
}
|
||||
req.SetXForwarded()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Route is evaluating the policies on the request and returns the RoutingInfo if successful.
|
||||
func (rt Router) Route(r *http.Request) (RoutingInfo, bool) {
|
||||
pol, err := rt.policySelector(r)
|
||||
if err != nil {
|
||||
rt.logger.Error().Err(err).Msg("Error while selecting pol")
|
||||
return noInfo, false
|
||||
}
|
||||
|
||||
if _, ok := rt.rewriters[pol]; !ok {
|
||||
rt.logger.
|
||||
Error().
|
||||
Str("policy", pol).
|
||||
Msg("policy is not configured")
|
||||
return noInfo, false
|
||||
}
|
||||
|
||||
method := ""
|
||||
// find matching rewrite hook
|
||||
for _, rtype := range config.RouteTypes {
|
||||
var handler func(string, url.URL) bool
|
||||
switch rtype {
|
||||
case config.QueryRoute:
|
||||
handler = queryRouteMatcher
|
||||
case config.RegexRoute:
|
||||
handler = rt.regexRouteMatcher
|
||||
case config.PrefixRoute:
|
||||
fallthrough
|
||||
default:
|
||||
handler = prefixRouteMatcher
|
||||
}
|
||||
if rt.rewriters[pol][rtype][r.Method] != nil {
|
||||
// use specific method
|
||||
method = r.Method
|
||||
} else {
|
||||
method = ""
|
||||
}
|
||||
|
||||
for _, ri := range rt.rewriters[pol][rtype][method] {
|
||||
if handler(ri.endpoint, *r.URL) {
|
||||
rt.logger.Debug().
|
||||
Str("policy", pol).
|
||||
Str("method", r.Method).
|
||||
Str("prefix", ri.endpoint).
|
||||
Str("path", r.URL.Path).
|
||||
Str("routeType", string(rtype)).
|
||||
Msg("rewrite hook found")
|
||||
|
||||
return ri, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// override default rewrite hook with root. If any
|
||||
if ri := rt.rewriters[pol][config.PrefixRoute][method][0]; ri.endpoint == "/" { // try specific method
|
||||
return ri, true
|
||||
} else if ri := rt.rewriters[pol][config.PrefixRoute][""][0]; ri.endpoint == "/" { // fallback to unspecific method
|
||||
return ri, true
|
||||
}
|
||||
|
||||
rt.logger.
|
||||
Warn().
|
||||
Str("policy", pol).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("no rewrite hook found")
|
||||
return noInfo, false
|
||||
}
|
||||
|
||||
func (rt Router) regexRouteMatcher(pattern string, target url.URL) bool {
|
||||
matched, err := regexp.MatchString(pattern, target.String())
|
||||
if err != nil {
|
||||
rt.logger.Warn().Err(err).Str("pattern", pattern).Msg("regex with pattern failed")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func prefixRouteMatcher(prefix string, target url.URL) bool {
|
||||
cleanTarget := path.Clean(target.Path)
|
||||
if strings.HasSuffix(target.Path, "/") {
|
||||
cleanTarget += "/"
|
||||
}
|
||||
return strings.HasPrefix(cleanTarget, prefix) && prefix != "/"
|
||||
}
|
||||
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
func queryRouteMatcher(endpoint string, target url.URL) bool {
|
||||
u, _ := url.Parse(endpoint)
|
||||
if !strings.HasPrefix(path.Clean(target.Path), u.Path) || endpoint == "/" {
|
||||
return false
|
||||
}
|
||||
q := u.Query()
|
||||
if len(q) == 0 {
|
||||
return false
|
||||
}
|
||||
tq := target.Query()
|
||||
for k := range q {
|
||||
if q.Get(k) != tq.Get(k) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SetRoutingInfo puts the routing info in the context.
|
||||
func SetRoutingInfo(parent context.Context, ri RoutingInfo) context.Context {
|
||||
return context.WithValue(parent, routingInfoCtxKey{}, ri)
|
||||
}
|
||||
|
||||
// ContextRoutingInfo gets the routing information from the context.
|
||||
func ContextRoutingInfo(ctx context.Context) RoutingInfo {
|
||||
val := ctx.Value(routingInfoCtxKey{})
|
||||
return val.(RoutingInfo)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config/defaults"
|
||||
"go-micro.dev/v4/selector"
|
||||
)
|
||||
|
||||
type matchertest struct {
|
||||
method, endpoint, target string
|
||||
unprotected bool
|
||||
matches bool
|
||||
}
|
||||
|
||||
func TestPrefixRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: "/foobar", target: "/foobar/baz/some/url", matches: true},
|
||||
{endpoint: "/fobar", target: "/foobar/baz/some/url", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := prefixRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("PrefixRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: "/foobar?parameter=true", target: "/foobar/baz/some/url?parameter=true", matches: true},
|
||||
{endpoint: "/foobar", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{endpoint: "/foobar?parameter=false", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{endpoint: "/foobar?parameter=false&other=true", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
{
|
||||
endpoint: "/foobar?parameter=false&other=true",
|
||||
target: "/foobar/baz/some/url?parameter=false&other=true",
|
||||
matches: true,
|
||||
},
|
||||
{endpoint: "/fobar", target: "/foobar", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := queryRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("QueryRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexRouteMatcher(t *testing.T) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
cfg.Policies = defaults.DefaultPolicies()
|
||||
reg := registry.GetRegistry()
|
||||
sel := selector.NewSelector(selector.Registry(reg))
|
||||
rt := New(sel, cfg.PolicySelector, cfg.Policies, log.NewLogger())
|
||||
|
||||
table := []matchertest{
|
||||
{endpoint: ".*some\\/url.*parameter=true", target: "/foobar/baz/some/url?parameter=true", matches: true},
|
||||
{endpoint: "([\\])\\w+", target: "/foobar/baz/some/url?parameter=true", matches: false},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
u, _ := url.Parse(test.target)
|
||||
matched := rt.regexRouteMatcher(test.endpoint, *u)
|
||||
if matched != test.matches {
|
||||
t.Errorf("RegexRouteMatcher returned %t expected %t for endpoint: %s and target %s",
|
||||
matched, test.matches, test.endpoint, u.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleJoiningSlash(t *testing.T) {
|
||||
type test struct {
|
||||
a, b, result string
|
||||
}
|
||||
|
||||
table := []test{
|
||||
{a: "a", b: "b", result: "a/b"},
|
||||
{a: "a/", b: "b", result: "a/b"},
|
||||
{a: "a", b: "/b", result: "a/b"},
|
||||
{a: "a/", b: "/b", result: "a/b"},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
p := singleJoiningSlash(test.a, test.b)
|
||||
if p != test.result {
|
||||
t.Errorf("SingleJoiningSlash got %s expected %s", p, test.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "ok")
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
policySelectorCfg := &config.PolicySelector{
|
||||
Static: &config.StaticSelectorConf{
|
||||
Policy: "default",
|
||||
},
|
||||
}
|
||||
|
||||
policies := []config.Policy{
|
||||
{
|
||||
Name: "default",
|
||||
Routes: []config.Route{
|
||||
{Type: config.PrefixRoute, Endpoint: "/web/unprotected/demo/", Backend: "http://web", Unprotected: true},
|
||||
{Type: config.PrefixRoute, Endpoint: "/dav", Backend: "http://frontend"},
|
||||
{Type: config.PrefixRoute, Method: "REPORT", Endpoint: "/dav", Backend: "http://qsfera-webdav"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
sel := selector.NewSelector(selector.Registry(reg))
|
||||
router := New(sel, policySelectorCfg, policies, log.NewLogger())
|
||||
|
||||
table := []matchertest{
|
||||
{method: "PROPFIND", endpoint: "/dav/files/demo/", target: "frontend"},
|
||||
{method: "REPORT", endpoint: "/dav/files/demo/", target: "qsfera-webdav"},
|
||||
{method: "GET", endpoint: "/web/unprotected/demo/", target: "web", unprotected: true},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
r := httptest.NewRequest(test.method, test.endpoint, nil)
|
||||
routingInfo, ok := router.Route(r)
|
||||
if !ok {
|
||||
t.Errorf("TestRouter router.Route failed to route the request.")
|
||||
}
|
||||
|
||||
if routingInfo.IsRouteUnprotected() != test.unprotected {
|
||||
t.Errorf("TestRouter route flag unprotected expected to be %t got %t", test.unprotected, routingInfo.IsRouteUnprotected())
|
||||
}
|
||||
|
||||
pr := &httputil.ProxyRequest{
|
||||
In: r,
|
||||
Out: r.Clone(context.Background()),
|
||||
}
|
||||
routingInfo.Rewrite()(pr)
|
||||
|
||||
if pr.Out.URL.Host != test.target {
|
||||
t.Errorf("TestRouter got host %s expected %s", pr.Out.URL.Host, test.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ggwhite/go-masker"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
|
||||
|
||||
readyHandlerConfiguration := healthHandlerConfiguration.
|
||||
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint))
|
||||
|
||||
var configDumpFunc http.HandlerFunc = configDump(options.Config)
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
|
||||
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
|
||||
debug.ConfigDump(configDumpFunc),
|
||||
), nil
|
||||
}
|
||||
|
||||
// configDump implements the config dump
|
||||
func configDump(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
maskedCfg, err := masker.Struct(cfg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(maskedCfg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/metrics"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Handler http.Handler
|
||||
Metrics *metrics.Metrics
|
||||
Flags []pflag.Flag
|
||||
Middlewares alice.Chain
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Flags provides a function to set the flags option.
|
||||
func Flags(flags ...pflag.Flag) Option {
|
||||
return func(o *Options) {
|
||||
o.Flags = append(o.Flags, flags...)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler provides a function to set the Handler option.
|
||||
func Handler(h http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Handler = h
|
||||
}
|
||||
}
|
||||
|
||||
// Middlewares provides a function to register middlewares
|
||||
func Middlewares(val alice.Chain) Option {
|
||||
return func(o *Options) {
|
||||
o.Middlewares = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
pkgcrypto "github.com/qsfera/server/pkg/crypto"
|
||||
"github.com/qsfera/server/pkg/service/http"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
l := options.Logger
|
||||
httpCfg := options.Config.HTTP
|
||||
|
||||
if options.Config.HTTP.TLS {
|
||||
l.Warn().Msgf("No tls certificate provided, using a generated one")
|
||||
_, certErr := os.Stat(httpCfg.TLSCert)
|
||||
_, keyErr := os.Stat(httpCfg.TLSKey)
|
||||
|
||||
if os.IsNotExist(certErr) || os.IsNotExist(keyErr) {
|
||||
// GenCert has side effects as it writes 2 files to the binary running location
|
||||
if err := pkgcrypto.GenCert(httpCfg.TLSCert, httpCfg.TLSKey, l); err != nil {
|
||||
l.Fatal().Err(err).Msgf("Could not generate test-certificate")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
chain := options.Middlewares.Then(options.Handler)
|
||||
|
||||
service, err := http.NewService(
|
||||
http.Name(options.Config.Service.Name),
|
||||
http.Version(version.GetString()),
|
||||
http.TLSConfig(shared.HTTPServiceTLS{
|
||||
Enabled: options.Config.HTTP.TLS,
|
||||
Cert: options.Config.HTTP.TLSCert,
|
||||
Key: options.Config.HTTP.TLSKey,
|
||||
}),
|
||||
http.Logger(options.Logger),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Context(options.Context),
|
||||
http.Flags(options.Flags...),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing http service")
|
||||
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), chain); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package staticroutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
|
||||
bcl "github.com/qsfera/server/services/proxy/pkg/staticroutes/internal/backchannellogout"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
// NewRecordKey converts the subject and session to a base64 encoded key
|
||||
var NewRecordKey = bcl.NewKey
|
||||
|
||||
// backchannelLogout handles backchannel logout requests from the identity provider and invalidates the related sessions in the cache
|
||||
// spec: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest
|
||||
//
|
||||
// known side effects of backchannel logout in keycloak:
|
||||
//
|
||||
// - keyCloak "Sign out all active sessions" does not send a backchannel logout request,
|
||||
// as the devs mention, this may lead to thousands of backchannel logout requests,
|
||||
// therefore, they recommend a short token lifetime.
|
||||
// https://github.com/keycloak/keycloak/issues/27342#issuecomment-2408461913
|
||||
//
|
||||
// - keyCloak user self-service portal, "Sign out all devices" may not send a backchannel
|
||||
// logout request for each session, it's not mentionex explicitly,
|
||||
// but maybe the reason for that is the same as for "Sign out all active sessions"
|
||||
// to prevent a flood of backchannel logout requests.
|
||||
//
|
||||
// - if the keycloak setting "Backchannel logout session required" is disabled (or the token has no session id),
|
||||
// we resolve the session by the subject which can lead to multiple session records (subject.*),
|
||||
// we then send a logout event (sse) to each connected client and delete our stored cache record (subject.session & claim).
|
||||
// all sessions besides the one that triggered the backchannel logout continue to exist in the identity provider,
|
||||
// so the user will not be fully logged out until all sessions are logged out or expired.
|
||||
// this leads to the situation that web renders the logout view even if the instance is not fully logged out yet.
|
||||
func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Request) {
|
||||
logger := s.Logger.SubloggerWithRequestID(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Warn().Err(err).Msg("ParseForm failed")
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logoutToken, err := s.OidcClient.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token"))
|
||||
if err != nil {
|
||||
msg := "failed to verify logout token"
|
||||
logger.Warn().Err(err).Msg(msg)
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
|
||||
return
|
||||
}
|
||||
|
||||
lookupKey, err := bcl.NewKey(logoutToken.Subject, logoutToken.SessionId)
|
||||
if err != nil {
|
||||
msg := "failed to build key from logout token"
|
||||
logger.Warn().Err(err).Msg(msg)
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
|
||||
return
|
||||
}
|
||||
|
||||
requestSubjectAndSession, err := bcl.NewSuSe(lookupKey)
|
||||
if err != nil {
|
||||
msg := "failed to build subjec.session from lookupKey"
|
||||
logger.Error().Err(err).Msg(msg)
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
|
||||
return
|
||||
}
|
||||
|
||||
lookupRecords, err := bcl.GetLogoutRecords(requestSubjectAndSession, s.UserInfoCache)
|
||||
if errors.Is(err, microstore.ErrNotFound) || len(lookupRecords) == 0 {
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
msg := "failed to read userinfo cache"
|
||||
logger.Error().Err(err).Msg(msg)
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
|
||||
return
|
||||
}
|
||||
|
||||
for _, record := range lookupRecords {
|
||||
// the record key is in the format "subject.session" or ".session"
|
||||
// the record value is the key of the record that contains the claim in its value
|
||||
key, value := record.Key, string(record.Value)
|
||||
|
||||
subjectSession, err := bcl.NewSuSe(key)
|
||||
if err != nil {
|
||||
// never leak any key-related information
|
||||
logger.Warn().Err(err).Msgf("failed to parse key: %s", key)
|
||||
continue
|
||||
}
|
||||
|
||||
session, err := subjectSession.Session()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msgf("failed to read session for: %s", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if requestSubjectAndSession.Mode() == bcl.LogoutModeSession {
|
||||
if err := s.publishBackchannelLogoutEvent(r.Context(), session, value); err != nil {
|
||||
s.Logger.Warn().Err(err).Msgf("failed to publish backchannel logout event for: %s", key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
err = s.UserInfoCache.Delete(value)
|
||||
if err != nil && !errors.Is(err, microstore.ErrNotFound) {
|
||||
// we have to return a 400 BadRequest when we fail to delete the session
|
||||
// https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.8
|
||||
msg := "failed to delete record"
|
||||
s.Logger.Warn().Err(err).Msgf("%s for: %s", msg, key)
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg})
|
||||
return
|
||||
}
|
||||
|
||||
// we can ignore errors when deleting the lookup record
|
||||
err = s.UserInfoCache.Delete(key)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msgf("failed to delete record for: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, nil)
|
||||
}
|
||||
|
||||
// publishBackchannelLogoutEvent publishes a backchannel logout event when the callback revived from the identity provider
|
||||
func (s *StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, sessionId, claimKey string) error {
|
||||
if s.EventsPublisher == nil {
|
||||
return errors.New("events publisher not set")
|
||||
}
|
||||
|
||||
claimRecords, err := s.UserInfoCache.Read(claimKey)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to read userinfo cache: %w", err)
|
||||
case len(claimRecords) == 0:
|
||||
return fmt.Errorf("no claim found for key: %s", claimKey)
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err = msgpack.Unmarshal(claimRecords[0].Value, &claims); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
oidcClaim, ok := claims[s.Config.UserOIDCClaim].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get claim %w", err)
|
||||
}
|
||||
|
||||
user, _, err := s.UserProvider.GetUserByClaims(ctx, s.Config.UserCS3Claim, oidcClaim)
|
||||
if err != nil || user.GetId() == nil {
|
||||
return fmt.Errorf("failed to get user by claims: %w", err)
|
||||
}
|
||||
|
||||
e := events.BackchannelLogout{
|
||||
Executant: user.GetId(),
|
||||
SessionId: sessionId,
|
||||
Timestamp: utils.TSNow(),
|
||||
}
|
||||
|
||||
if err := events.Publish(ctx, s.EventsPublisher, e); err != nil {
|
||||
return fmt.Errorf("failed to publish user logout event %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// package backchannellogout provides functions to classify and lookup
|
||||
// backchannel logout records from the cache store.
|
||||
|
||||
package backchannellogout
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
microstore "go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
// keyEncoding is the base64 encoding used for session and subject keys
|
||||
var keyEncoding = base64.URLEncoding
|
||||
|
||||
// ErrInvalidKey indicates that the provided key does not conform to the expected format.
|
||||
var ErrInvalidKey = errors.New("invalid key format")
|
||||
|
||||
// NewKey converts the subject and session to a base64 encoded key
|
||||
func NewKey(subject, session string) (string, error) {
|
||||
subjectSession := strings.Join([]string{
|
||||
keyEncoding.EncodeToString([]byte(subject)),
|
||||
keyEncoding.EncodeToString([]byte(session)),
|
||||
}, ".")
|
||||
|
||||
if subjectSession == "." {
|
||||
return "", ErrInvalidKey
|
||||
}
|
||||
|
||||
return subjectSession, nil
|
||||
}
|
||||
|
||||
// LogoutMode defines the mode of backchannel logout, either by session or by subject
|
||||
type LogoutMode int
|
||||
|
||||
const (
|
||||
// LogoutModeUndefined is used when the logout mode cannot be determined
|
||||
LogoutModeUndefined LogoutMode = iota
|
||||
// LogoutModeSubject is used when the logout mode is determined by the subject
|
||||
LogoutModeSubject
|
||||
// LogoutModeSession is used when the logout mode is determined by the session id
|
||||
LogoutModeSession
|
||||
)
|
||||
|
||||
// ErrDecoding is returned when decoding fails
|
||||
var ErrDecoding = errors.New("failed to decode")
|
||||
|
||||
// SuSe 🦎 ;) is a struct that groups the subject and session together
|
||||
// to prevent mix-ups for ('session, subject' || 'subject, session')
|
||||
// return values.
|
||||
type SuSe struct {
|
||||
encodedSubject string
|
||||
encodedSession string
|
||||
}
|
||||
|
||||
// Subject decodes and returns the subject or an error
|
||||
func (suse SuSe) Subject() (string, error) {
|
||||
subject, err := keyEncoding.DecodeString(suse.encodedSubject)
|
||||
if err != nil {
|
||||
return "", errors.Join(errors.New("failed to decode subject"), ErrDecoding, err)
|
||||
}
|
||||
|
||||
return string(subject), nil
|
||||
}
|
||||
|
||||
// Session decodes and returns the session or an error
|
||||
func (suse SuSe) Session() (string, error) {
|
||||
subject, err := keyEncoding.DecodeString(suse.encodedSession)
|
||||
if err != nil {
|
||||
return "", errors.Join(errors.New("failed to decode session"), ErrDecoding, err)
|
||||
}
|
||||
|
||||
return string(subject), nil
|
||||
}
|
||||
|
||||
// Mode determines the backchannel logout mode based on the presence of subject and session
|
||||
func (suse SuSe) Mode() LogoutMode {
|
||||
switch {
|
||||
case suse.encodedSession == "" && suse.encodedSubject != "":
|
||||
return LogoutModeSubject
|
||||
case suse.encodedSession != "":
|
||||
return LogoutModeSession
|
||||
default:
|
||||
return LogoutModeUndefined
|
||||
}
|
||||
}
|
||||
|
||||
// ErrInvalidSubjectOrSession is returned when the provided key does not match the expected key format
|
||||
var ErrInvalidSubjectOrSession = errors.New("invalid subject or session")
|
||||
|
||||
// NewSuSe parses the subject and session id from the given key and returns a SuSe struct
|
||||
func NewSuSe(key string) (SuSe, error) {
|
||||
suse := SuSe{}
|
||||
keys := strings.Split(key, ".")
|
||||
switch len(keys) {
|
||||
case 1:
|
||||
suse.encodedSession = keys[0]
|
||||
case 2:
|
||||
suse.encodedSubject = keys[0]
|
||||
suse.encodedSession = keys[1]
|
||||
default:
|
||||
return suse, ErrInvalidSubjectOrSession
|
||||
}
|
||||
|
||||
if suse.encodedSubject == "" && suse.encodedSession == "" {
|
||||
return suse, ErrInvalidSubjectOrSession
|
||||
}
|
||||
|
||||
if _, err := suse.Subject(); err != nil {
|
||||
return suse, errors.Join(ErrInvalidSubjectOrSession, err)
|
||||
}
|
||||
|
||||
if _, err := suse.Session(); err != nil {
|
||||
return suse, errors.Join(ErrInvalidSubjectOrSession, err)
|
||||
}
|
||||
|
||||
if mode := suse.Mode(); mode == LogoutModeUndefined {
|
||||
return suse, ErrInvalidSubjectOrSession
|
||||
}
|
||||
|
||||
return suse, nil
|
||||
}
|
||||
|
||||
// ErrSuspiciousCacheResult is returned when the cache result is suspicious
|
||||
var ErrSuspiciousCacheResult = errors.New("suspicious cache result")
|
||||
|
||||
// GetLogoutRecords retrieves the records from the user info cache based on the backchannel
|
||||
// logout mode and the provided SuSe struct.
|
||||
// it uses a seperator to prevent sufix and prefix exploration in the cache and checks
|
||||
// if the retrieved records match the requested subject and or session id as well, to prevent false positives.
|
||||
func GetLogoutRecords(suse SuSe, store microstore.Store) ([]*microstore.Record, error) {
|
||||
var key string
|
||||
var opts []microstore.ReadOption
|
||||
switch {
|
||||
case suse.Mode() == LogoutModeSubject && suse.encodedSubject != "":
|
||||
// the dot at the end prevents prefix exploration in the cache,
|
||||
// so only keys that start with 'subject.*' will be returned, but not 'sub*'.
|
||||
key = suse.encodedSubject + "."
|
||||
opts = append(opts, microstore.ReadPrefix())
|
||||
case suse.Mode() == LogoutModeSession && suse.encodedSession != "":
|
||||
// the dot at the beginning prevents sufix exploration in the cache,
|
||||
// so only keys that end with '*.session' will be returned, but not '*sion'.
|
||||
key = "." + suse.encodedSession
|
||||
opts = append(opts, microstore.ReadSuffix())
|
||||
default:
|
||||
return nil, errors.Join(errors.New("cannot determine logout mode"), ErrSuspiciousCacheResult)
|
||||
}
|
||||
|
||||
// the go micro memory store requires a limit to work, why???
|
||||
records, err := store.Read(key, append(opts, microstore.ReadLimit(1000))...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, microstore.ErrNotFound
|
||||
}
|
||||
|
||||
if suse.Mode() == LogoutModeSession && len(records) > 1 {
|
||||
return nil, errors.Join(errors.New("multiple session records found"), ErrSuspiciousCacheResult)
|
||||
}
|
||||
|
||||
// double-check if the found records match the requested subject and or session id as well,
|
||||
// to prevent false positives.
|
||||
for _, record := range records {
|
||||
recordSuSe, err := NewSuSe(record.Key)
|
||||
if err != nil {
|
||||
// never leak any key-related information
|
||||
return nil, errors.Join(errors.New("failed to parse key"), ErrSuspiciousCacheResult, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
// in subject mode, the subject must match, but the session id can be different
|
||||
case suse.Mode() == LogoutModeSubject && suse.encodedSubject == recordSuSe.encodedSubject:
|
||||
continue
|
||||
// in session mode, the session id must match, but the subject can be different
|
||||
case suse.Mode() == LogoutModeSession && suse.encodedSession == recordSuSe.encodedSession:
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, errors.Join(errors.New("key does not match the requested subject or session"), ErrSuspiciousCacheResult)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
package backchannellogout
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go-micro.dev/v4/store"
|
||||
|
||||
"github.com/qsfera/server/services/proxy/pkg/staticroutes/internal/backchannellogout/mocks"
|
||||
)
|
||||
|
||||
func mustNewKey(t *testing.T, subject, session string) string {
|
||||
key, err := NewKey(subject, session)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func mustNewSuSe(t *testing.T, subject, session string) SuSe {
|
||||
suse, err := NewSuSe(mustNewKey(t, subject, session))
|
||||
require.NoError(t, err)
|
||||
return suse
|
||||
}
|
||||
|
||||
func TestNewKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subject string
|
||||
session string
|
||||
wantKey string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "key variation: 'subject.session'",
|
||||
subject: "subject",
|
||||
session: "session",
|
||||
wantKey: "c3ViamVjdA==.c2Vzc2lvbg==",
|
||||
},
|
||||
{
|
||||
name: "key variation: 'subject.'",
|
||||
subject: "subject",
|
||||
wantKey: "c3ViamVjdA==.",
|
||||
},
|
||||
{
|
||||
name: "key variation: '.session'",
|
||||
session: "session",
|
||||
wantKey: ".c2Vzc2lvbg==",
|
||||
},
|
||||
{
|
||||
name: "key variation: '.'",
|
||||
wantErr: ErrInvalidKey,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, err := NewKey(tt.subject, tt.session)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
require.Equal(t, tt.wantKey, key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSuSe(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
wantSubject string
|
||||
wantSession string
|
||||
wantMode LogoutMode
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "key variation: '.session'",
|
||||
key: mustNewKey(t, "", "session"),
|
||||
wantSession: "session",
|
||||
wantMode: LogoutModeSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: 'session'",
|
||||
key: mustNewKey(t, "", "session"),
|
||||
wantSession: "session",
|
||||
wantMode: LogoutModeSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: 'subject.'",
|
||||
key: mustNewKey(t, "subject", ""),
|
||||
wantSubject: "subject",
|
||||
wantMode: LogoutModeSubject,
|
||||
},
|
||||
{
|
||||
name: "key variation: 'subject.session'",
|
||||
key: mustNewKey(t, "subject", "session"),
|
||||
wantSubject: "subject",
|
||||
wantSession: "session",
|
||||
wantMode: LogoutModeSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: 'dot'",
|
||||
key: ".",
|
||||
wantErr: ErrInvalidSubjectOrSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: 'empty'",
|
||||
key: "",
|
||||
wantErr: ErrInvalidSubjectOrSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: string('subject.session')",
|
||||
key: "subject.session",
|
||||
wantErr: ErrInvalidSubjectOrSession,
|
||||
wantMode: LogoutModeSession,
|
||||
},
|
||||
{
|
||||
name: "key variation: string('subject.')",
|
||||
key: "subject.",
|
||||
wantErr: ErrInvalidSubjectOrSession,
|
||||
wantMode: LogoutModeSubject,
|
||||
},
|
||||
{
|
||||
name: "key variation: string('.session')",
|
||||
key: ".session",
|
||||
wantErr: ErrInvalidSubjectOrSession,
|
||||
wantMode: LogoutModeSession,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
suSe, err := NewSuSe(tt.key)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
|
||||
mode := suSe.Mode()
|
||||
require.Equal(t, tt.wantMode, mode)
|
||||
|
||||
subject, _ := suSe.Subject()
|
||||
require.Equal(t, tt.wantSubject, subject)
|
||||
|
||||
session, _ := suSe.Session()
|
||||
require.Equal(t, tt.wantSession, session)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogoutRecords(t *testing.T) {
|
||||
sessionStore := store.NewMemoryStore()
|
||||
|
||||
recordClaimA := &store.Record{Key: "claim-a", Value: []byte("claim-a-data")}
|
||||
recordClaimB := &store.Record{Key: "claim-b", Value: []byte("claim-b-data")}
|
||||
recordClaimC := &store.Record{Key: "claim-c", Value: []byte("claim-c-data")}
|
||||
recordClaimD := &store.Record{Key: "claim-d", Value: []byte("claim-d-data")}
|
||||
recordSessionA := &store.Record{Key: mustNewKey(t, "", "session-a"), Value: []byte(recordClaimA.Key)}
|
||||
recordSessionB := &store.Record{Key: mustNewKey(t, "", "session-b"), Value: []byte(recordClaimB.Key)}
|
||||
recordSubjectASessionC := &store.Record{Key: mustNewKey(t, "subject-a", "session-c"), Value: []byte(recordSessionA.Key)}
|
||||
recordSubjectASessionD := &store.Record{Key: mustNewKey(t, "subject-a", "session-d"), Value: []byte(recordSessionA.Key)}
|
||||
|
||||
for _, r := range []*store.Record{
|
||||
recordClaimA,
|
||||
recordClaimB,
|
||||
recordClaimC,
|
||||
recordClaimD,
|
||||
recordSessionA,
|
||||
recordSessionB,
|
||||
recordSubjectASessionC,
|
||||
recordSubjectASessionD,
|
||||
} {
|
||||
require.NoError(t, sessionStore.Write(r))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
suSe SuSe
|
||||
store func(t *testing.T) store.Store
|
||||
wantRecords []*store.Record
|
||||
wantErrs []error
|
||||
}{
|
||||
{
|
||||
name: "fails if multiple session records are found",
|
||||
suSe: mustNewSuSe(t, "", "session-a"),
|
||||
store: func(t *testing.T) store.Store {
|
||||
s := mocks.NewStore(t)
|
||||
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
|
||||
recordSessionA,
|
||||
recordSessionB,
|
||||
}, nil)
|
||||
return s
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{ErrSuspiciousCacheResult}},
|
||||
{
|
||||
name: "fails if the record key is not ok",
|
||||
suSe: mustNewSuSe(t, "", "session-a"),
|
||||
store: func(t *testing.T) store.Store {
|
||||
s := mocks.NewStore(t)
|
||||
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
|
||||
{Key: "invalid.record.key"},
|
||||
}, nil)
|
||||
return s
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{ErrInvalidSubjectOrSession, ErrSuspiciousCacheResult},
|
||||
},
|
||||
{
|
||||
name: "fails if the session does not match the retrieved record",
|
||||
suSe: mustNewSuSe(t, "", "session-a"),
|
||||
store: func(t *testing.T) store.Store {
|
||||
s := mocks.NewStore(t)
|
||||
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
|
||||
recordSessionB,
|
||||
}, nil)
|
||||
return s
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{ErrSuspiciousCacheResult}},
|
||||
{
|
||||
name: "fails if the subject does not match the retrieved record",
|
||||
suSe: mustNewSuSe(t, "subject-a", ""),
|
||||
store: func(t *testing.T) store.Store {
|
||||
s := mocks.NewStore(t)
|
||||
s.EXPECT().Read(mock.Anything, mock.Anything).Return([]*store.Record{
|
||||
recordSessionB,
|
||||
}, nil)
|
||||
return s
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{ErrSuspiciousCacheResult}},
|
||||
// key variation tests
|
||||
{
|
||||
name: "key variation: 'session-a'",
|
||||
suSe: mustNewSuSe(t, "", "session-a"),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{recordSessionA},
|
||||
},
|
||||
{
|
||||
name: "key variation: 'session-b'",
|
||||
suSe: mustNewSuSe(t, "", "session-b"),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{recordSessionB},
|
||||
},
|
||||
{
|
||||
name: "key variation: 'session-c'",
|
||||
suSe: mustNewSuSe(t, "", "session-c"),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{recordSubjectASessionC},
|
||||
},
|
||||
{
|
||||
name: "key variation: 'ession-c'",
|
||||
suSe: mustNewSuSe(t, "", "ession-c"),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{store.ErrNotFound},
|
||||
},
|
||||
{
|
||||
name: "key variation: 'subject-a'",
|
||||
suSe: mustNewSuSe(t, "subject-a", ""),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{recordSubjectASessionC, recordSubjectASessionD},
|
||||
},
|
||||
{
|
||||
name: "key variation: 'subject-'",
|
||||
suSe: mustNewSuSe(t, "subject-", ""),
|
||||
store: func(*testing.T) store.Store {
|
||||
return sessionStore
|
||||
},
|
||||
wantRecords: []*store.Record{},
|
||||
wantErrs: []error{store.ErrNotFound},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
records, err := GetLogoutRecords(tt.suSe, tt.store(t))
|
||||
for _, wantErr := range tt.wantErrs {
|
||||
require.ErrorIs(t, err, wantErr)
|
||||
}
|
||||
require.Len(t, records, len(tt.wantRecords))
|
||||
|
||||
sortRecords := func(r []*store.Record) []*store.Record {
|
||||
slices.SortFunc(r, func(a, b *store.Record) int {
|
||||
return strings.Compare(a.Key, b.Key)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
records = sortRecords(records)
|
||||
for i, wantRecords := range sortRecords(tt.wantRecords) {
|
||||
require.True(t, len(records) >= i+1)
|
||||
require.Equal(t, wantRecords.Key, records[i].Key)
|
||||
require.Equal(t, wantRecords.Value, records[i].Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
"go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
// NewStore creates a new instance of Store. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Store {
|
||||
mock := &Store{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Store is an autogenerated mock type for the Store type
|
||||
type Store struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Store_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Store) EXPECT() *Store_Expecter {
|
||||
return &Store_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Close provides a mock function for the type Store
|
||||
func (_mock *Store) Close() error {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Close")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
|
||||
type Store_Close_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Close is a helper method to define mock.On call
|
||||
func (_e *Store_Expecter) Close() *Store_Close_Call {
|
||||
return &Store_Close_Call{Call: _e.mock.On("Close")}
|
||||
}
|
||||
|
||||
func (_c *Store_Close_Call) Run(run func()) *Store_Close_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Close_Call) Return(err error) *Store_Close_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Close_Call) RunAndReturn(run func() error) *Store_Close_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Delete provides a mock function for the type Store
|
||||
func (_mock *Store) Delete(key string, opts ...store.DeleteOption) error {
|
||||
var tmpRet mock.Arguments
|
||||
if len(opts) > 0 {
|
||||
tmpRet = _mock.Called(key, opts)
|
||||
} else {
|
||||
tmpRet = _mock.Called(key)
|
||||
}
|
||||
ret := tmpRet
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, ...store.DeleteOption) error); ok {
|
||||
r0 = returnFunc(key, opts...)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type Store_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - key string
|
||||
// - opts ...store.DeleteOption
|
||||
func (_e *Store_Expecter) Delete(key interface{}, opts ...interface{}) *Store_Delete_Call {
|
||||
return &Store_Delete_Call{Call: _e.mock.On("Delete",
|
||||
append([]interface{}{key}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *Store_Delete_Call) Run(run func(key string, opts ...store.DeleteOption)) *Store_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 []store.DeleteOption
|
||||
var variadicArgs []store.DeleteOption
|
||||
if len(args) > 1 {
|
||||
variadicArgs = args[1].([]store.DeleteOption)
|
||||
}
|
||||
arg1 = variadicArgs
|
||||
run(
|
||||
arg0,
|
||||
arg1...,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Delete_Call) Return(err error) *Store_Delete_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Delete_Call) RunAndReturn(run func(key string, opts ...store.DeleteOption) error) *Store_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Init provides a mock function for the type Store
|
||||
func (_mock *Store) Init(options ...store.Option) error {
|
||||
var tmpRet mock.Arguments
|
||||
if len(options) > 0 {
|
||||
tmpRet = _mock.Called(options)
|
||||
} else {
|
||||
tmpRet = _mock.Called()
|
||||
}
|
||||
ret := tmpRet
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Init")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(...store.Option) error); ok {
|
||||
r0 = returnFunc(options...)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init'
|
||||
type Store_Init_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Init is a helper method to define mock.On call
|
||||
// - options ...store.Option
|
||||
func (_e *Store_Expecter) Init(options ...interface{}) *Store_Init_Call {
|
||||
return &Store_Init_Call{Call: _e.mock.On("Init",
|
||||
append([]interface{}{}, options...)...)}
|
||||
}
|
||||
|
||||
func (_c *Store_Init_Call) Run(run func(options ...store.Option)) *Store_Init_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 []store.Option
|
||||
var variadicArgs []store.Option
|
||||
if len(args) > 0 {
|
||||
variadicArgs = args[0].([]store.Option)
|
||||
}
|
||||
arg0 = variadicArgs
|
||||
run(
|
||||
arg0...,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Init_Call) Return(err error) *Store_Init_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Init_Call) RunAndReturn(run func(options ...store.Option) error) *Store_Init_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// List provides a mock function for the type Store
|
||||
func (_mock *Store) List(opts ...store.ListOption) ([]string, error) {
|
||||
var tmpRet mock.Arguments
|
||||
if len(opts) > 0 {
|
||||
tmpRet = _mock.Called(opts)
|
||||
} else {
|
||||
tmpRet = _mock.Called()
|
||||
}
|
||||
ret := tmpRet
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for List")
|
||||
}
|
||||
|
||||
var r0 []string
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(...store.ListOption) ([]string, error)); ok {
|
||||
return returnFunc(opts...)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(...store.ListOption) []string); ok {
|
||||
r0 = returnFunc(opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(...store.ListOption) error); ok {
|
||||
r1 = returnFunc(opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Store_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
|
||||
type Store_List_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// List is a helper method to define mock.On call
|
||||
// - opts ...store.ListOption
|
||||
func (_e *Store_Expecter) List(opts ...interface{}) *Store_List_Call {
|
||||
return &Store_List_Call{Call: _e.mock.On("List",
|
||||
append([]interface{}{}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *Store_List_Call) Run(run func(opts ...store.ListOption)) *Store_List_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 []store.ListOption
|
||||
var variadicArgs []store.ListOption
|
||||
if len(args) > 0 {
|
||||
variadicArgs = args[0].([]store.ListOption)
|
||||
}
|
||||
arg0 = variadicArgs
|
||||
run(
|
||||
arg0...,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_List_Call) Return(strings []string, err error) *Store_List_Call {
|
||||
_c.Call.Return(strings, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_List_Call) RunAndReturn(run func(opts ...store.ListOption) ([]string, error)) *Store_List_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Options provides a mock function for the type Store
|
||||
func (_mock *Store) Options() store.Options {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Options")
|
||||
}
|
||||
|
||||
var r0 store.Options
|
||||
if returnFunc, ok := ret.Get(0).(func() store.Options); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Get(0).(store.Options)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_Options_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Options'
|
||||
type Store_Options_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Options is a helper method to define mock.On call
|
||||
func (_e *Store_Expecter) Options() *Store_Options_Call {
|
||||
return &Store_Options_Call{Call: _e.mock.On("Options")}
|
||||
}
|
||||
|
||||
func (_c *Store_Options_Call) Run(run func()) *Store_Options_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Options_Call) Return(options store.Options) *Store_Options_Call {
|
||||
_c.Call.Return(options)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Options_Call) RunAndReturn(run func() store.Options) *Store_Options_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Read provides a mock function for the type Store
|
||||
func (_mock *Store) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
|
||||
var tmpRet mock.Arguments
|
||||
if len(opts) > 0 {
|
||||
tmpRet = _mock.Called(key, opts)
|
||||
} else {
|
||||
tmpRet = _mock.Called(key)
|
||||
}
|
||||
ret := tmpRet
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Read")
|
||||
}
|
||||
|
||||
var r0 []*store.Record
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, ...store.ReadOption) ([]*store.Record, error)); ok {
|
||||
return returnFunc(key, opts...)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(string, ...store.ReadOption) []*store.Record); ok {
|
||||
r0 = returnFunc(key, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*store.Record)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(string, ...store.ReadOption) error); ok {
|
||||
r1 = returnFunc(key, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Store_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
|
||||
type Store_Read_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Read is a helper method to define mock.On call
|
||||
// - key string
|
||||
// - opts ...store.ReadOption
|
||||
func (_e *Store_Expecter) Read(key interface{}, opts ...interface{}) *Store_Read_Call {
|
||||
return &Store_Read_Call{Call: _e.mock.On("Read",
|
||||
append([]interface{}{key}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *Store_Read_Call) Run(run func(key string, opts ...store.ReadOption)) *Store_Read_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 []store.ReadOption
|
||||
var variadicArgs []store.ReadOption
|
||||
if len(args) > 1 {
|
||||
variadicArgs = args[1].([]store.ReadOption)
|
||||
}
|
||||
arg1 = variadicArgs
|
||||
run(
|
||||
arg0,
|
||||
arg1...,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Read_Call) Return(records []*store.Record, err error) *Store_Read_Call {
|
||||
_c.Call.Return(records, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Read_Call) RunAndReturn(run func(key string, opts ...store.ReadOption) ([]*store.Record, error)) *Store_Read_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// String provides a mock function for the type Store
|
||||
func (_mock *Store) String() string {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for String")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
if returnFunc, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_String_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'String'
|
||||
type Store_String_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// String is a helper method to define mock.On call
|
||||
func (_e *Store_Expecter) String() *Store_String_Call {
|
||||
return &Store_String_Call{Call: _e.mock.On("String")}
|
||||
}
|
||||
|
||||
func (_c *Store_String_Call) Run(run func()) *Store_String_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_String_Call) Return(s string) *Store_String_Call {
|
||||
_c.Call.Return(s)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_String_Call) RunAndReturn(run func() string) *Store_String_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Write provides a mock function for the type Store
|
||||
func (_mock *Store) Write(r *store.Record, opts ...store.WriteOption) error {
|
||||
var tmpRet mock.Arguments
|
||||
if len(opts) > 0 {
|
||||
tmpRet = _mock.Called(r, opts)
|
||||
} else {
|
||||
tmpRet = _mock.Called(r)
|
||||
}
|
||||
ret := tmpRet
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Write")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(*store.Record, ...store.WriteOption) error); ok {
|
||||
r0 = returnFunc(r, opts...)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
|
||||
type Store_Write_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Write is a helper method to define mock.On call
|
||||
// - r *store.Record
|
||||
// - opts ...store.WriteOption
|
||||
func (_e *Store_Expecter) Write(r interface{}, opts ...interface{}) *Store_Write_Call {
|
||||
return &Store_Write_Call{Call: _e.mock.On("Write",
|
||||
append([]interface{}{r}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *Store_Write_Call) Run(run func(r *store.Record, opts ...store.WriteOption)) *Store_Write_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *store.Record
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*store.Record)
|
||||
}
|
||||
var arg1 []store.WriteOption
|
||||
var variadicArgs []store.WriteOption
|
||||
if len(args) > 1 {
|
||||
variadicArgs = args[1].([]store.WriteOption)
|
||||
}
|
||||
arg1 = variadicArgs
|
||||
run(
|
||||
arg0,
|
||||
arg1...,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Write_Call) Return(err error) *Store_Write_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_Write_Call) RunAndReturn(run func(r *store.Record, opts ...store.WriteOption) error) *Store_Write_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package staticroutes
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
)
|
||||
|
||||
var (
|
||||
wellKnownPath = "/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
// OIDCWellKnownRewrite is a handler that rewrites the /.well-known/openid-configuration endpoint for external IDPs.
|
||||
func (s *StaticRouteHandler) oIDCWellKnownRewrite(issuer string) http.HandlerFunc {
|
||||
oidcURL, _ := url.Parse(issuer)
|
||||
oidcURL.Path = path.Join(oidcURL.Path, wellKnownPath)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wellKnownRes, err := s.OidcHttpClient.Get(oidcURL.String())
|
||||
if err != nil {
|
||||
s.Logger.Error().
|
||||
Err(err).
|
||||
Str("handler", "oidc wellknown rewrite").
|
||||
Str("url", oidcURL.String()).
|
||||
Msg("get information from url failed")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
defer wellKnownRes.Body.Close()
|
||||
|
||||
copyHeader(w.Header(), wellKnownRes.Header)
|
||||
w.WriteHeader(wellKnownRes.StatusCode)
|
||||
_, err = io.Copy(w, wellKnownRes.Body)
|
||||
if err != nil {
|
||||
s.Logger.Error().
|
||||
Err(err).
|
||||
Str("handler", "oidc wellknown rewrite").
|
||||
Msg("copying response body failed")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyHeader(dst, src http.Header) {
|
||||
for k, vv := range src {
|
||||
for _, v := range vv {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package staticroutes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
// StaticRouteHandler defines a Route Handler for static routes
|
||||
type StaticRouteHandler struct {
|
||||
Prefix string
|
||||
Proxy http.Handler
|
||||
UserInfoCache microstore.Store
|
||||
Logger log.Logger
|
||||
Config config.Config
|
||||
OidcClient oidc.OIDCClient
|
||||
OidcHttpClient *http.Client
|
||||
EventsPublisher events.Publisher
|
||||
UserProvider backend.UserBackend
|
||||
}
|
||||
|
||||
type jse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
func (s *StaticRouteHandler) Handler() http.Handler {
|
||||
m := chi.NewMux()
|
||||
m.Route(s.Prefix, func(r chi.Router) {
|
||||
|
||||
// Wrapper for backchannel logout
|
||||
r.Post("/backchannel_logout", s.backchannelLogout)
|
||||
|
||||
// openid .well-known
|
||||
if s.Config.OIDC.RewriteWellKnown {
|
||||
r.Get("/.well-known/openid-configuration", s.oIDCWellKnownRewrite(s.Config.OIDC.Issuer))
|
||||
}
|
||||
|
||||
// Send all requests to the proxy handler
|
||||
r.HandleFunc("/*", s.Proxy.ServeHTTP)
|
||||
})
|
||||
|
||||
// Also send requests for methods unknown to chi to the proxy handler as well
|
||||
m.MethodNotAllowed(s.Proxy.ServeHTTP)
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountNotFound account not found
|
||||
ErrAccountNotFound = errors.New("user not found")
|
||||
// ErrAccountDisabled account disabled
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
// ErrNotSupported operation not supported by user-backend
|
||||
ErrNotSupported = errors.New("operation not supported")
|
||||
)
|
||||
|
||||
// UserBackend allows the proxy to retrieve users from different user-backends (accounts-service, CS3)
|
||||
type UserBackend interface {
|
||||
GetUserByClaims(ctx context.Context, claim, value string) (*cs3.User, string, error)
|
||||
Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error)
|
||||
CreateUserFromClaims(ctx context.Context, claims map[string]any) (*cs3.User, error)
|
||||
UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]any) error
|
||||
SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]any) error
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
utils "github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"go-micro.dev/v4/selector"
|
||||
)
|
||||
|
||||
type cs3backend struct {
|
||||
graphSelector selector.Selector
|
||||
Options
|
||||
}
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
logger log.Logger
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
selector selector.Selector
|
||||
machineAuthAPIKey string
|
||||
oidcISS string
|
||||
serviceAccount config.ServiceAccount
|
||||
autoProvisionClaims config.AutoProvisionClaims
|
||||
}
|
||||
|
||||
var (
|
||||
errGroupNotFound = errors.New("group not found")
|
||||
)
|
||||
|
||||
// WithLogger sets the logger option
|
||||
func WithLogger(l log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// WithRevaGatewaySelector set the gatewaySelector option
|
||||
func WithRevaGatewaySelector(selectable pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.gatewaySelector = selectable
|
||||
}
|
||||
}
|
||||
|
||||
// WithSelector set the Selector option
|
||||
func WithSelector(selector selector.Selector) Option {
|
||||
return func(o *Options) {
|
||||
o.selector = selector
|
||||
}
|
||||
}
|
||||
|
||||
// WithMachineAuthAPIKey configures the machine auth API key
|
||||
func WithMachineAuthAPIKey(ma string) Option {
|
||||
return func(o *Options) {
|
||||
o.machineAuthAPIKey = ma
|
||||
}
|
||||
}
|
||||
|
||||
// WithOIDCissuer set the OIDC issuer URL
|
||||
func WithOIDCissuer(oidcISS string) Option {
|
||||
return func(o *Options) {
|
||||
o.oidcISS = oidcISS
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceAccount configures the service account creator to use
|
||||
func WithServiceAccount(c config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.serviceAccount = c
|
||||
}
|
||||
}
|
||||
|
||||
func WithAutoProvisionClaims(claims config.AutoProvisionClaims) Option {
|
||||
return func(o *Options) {
|
||||
o.autoProvisionClaims = claims
|
||||
}
|
||||
}
|
||||
|
||||
// NewCS3UserBackend creates a user-provider which fetches users from a CS3 UserBackend
|
||||
func NewCS3UserBackend(opts ...Option) UserBackend {
|
||||
opt := Options{}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
b := cs3backend{
|
||||
Options: opt,
|
||||
graphSelector: opt.selector,
|
||||
}
|
||||
|
||||
return &b
|
||||
}
|
||||
|
||||
func (c *cs3backend) GetUserByClaims(ctx context.Context, claim, value string) (*cs3.User, string, error) {
|
||||
gatewayClient, err := c.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
|
||||
}
|
||||
|
||||
res, err := gatewayClient.Authenticate(ctx, &gateway.AuthenticateRequest{
|
||||
Type: "machine",
|
||||
ClientId: claim + ":" + value,
|
||||
ClientSecret: c.machineAuthAPIKey,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, "", fmt.Errorf("could not get user by claim %v with value %v: %w", claim, value, err)
|
||||
case res.Status.Code != rpcv1beta1.Code_CODE_OK:
|
||||
if res.Status.Code == rpcv1beta1.Code_CODE_NOT_FOUND {
|
||||
return nil, "", ErrAccountNotFound
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get user by claim %v with value %v : %s ", claim, value, res.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
user := res.User
|
||||
|
||||
return user, res.GetToken(), nil
|
||||
}
|
||||
|
||||
func (c *cs3backend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error) {
|
||||
gatewayClient, err := c.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
|
||||
}
|
||||
|
||||
res, err := gatewayClient.Authenticate(ctx, &gateway.AuthenticateRequest{
|
||||
Type: "basic",
|
||||
ClientId: username,
|
||||
ClientSecret: password,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, %w", username, err)
|
||||
case res.Status.Code != rpcv1beta1.Code_CODE_OK:
|
||||
return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, got code: %d", username, res.GetStatus().GetCode())
|
||||
}
|
||||
|
||||
return res.User, res.Token, nil
|
||||
}
|
||||
|
||||
// CreateUserFromClaims creates a new user via libregraph users API, taking the
|
||||
// attributes from the provided `claims` map. On success it returns the new
|
||||
// user. If the user already exist this is not considered an error and the
|
||||
// function will just return the existing user.
|
||||
func (c *cs3backend) CreateUserFromClaims(ctx context.Context, claims map[string]any) (*cs3.User, error) {
|
||||
gatewayClient, err := c.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return nil, err
|
||||
}
|
||||
newctx := context.Background()
|
||||
authRes, err := gatewayClient.Authenticate(newctx, &gateway.AuthenticateRequest{
|
||||
Type: "serviceaccounts",
|
||||
ClientId: c.serviceAccount.ServiceAccountID,
|
||||
ClientSecret: c.serviceAccount.ServiceAccountSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
|
||||
return nil, fmt.Errorf("error authenticating service user: %s", authRes.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
lgClient, err := c.setupLibregraphClient(newctx, authRes.GetToken())
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error setting up libregraph client.")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newUser, err := c.libregraphUserFromClaims(claims)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Interface("claims", claims).Msg("Error creating user from claims")
|
||||
return nil, fmt.Errorf("error creating user from claims: %w", err)
|
||||
}
|
||||
|
||||
req := lgClient.UsersApi.CreateUser(newctx).User(newUser)
|
||||
|
||||
created, resp, err := req.Execute()
|
||||
defer resp.Body.Close()
|
||||
|
||||
var reread bool
|
||||
if err != nil {
|
||||
if resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the user already exists here, some other request did already create it in parallel.
|
||||
// So just issue a Debug message and ignore the libregraph error otherwise
|
||||
var lerr error
|
||||
if reread, lerr = c.isAlreadyExists(resp); lerr != nil {
|
||||
c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
|
||||
return nil, err
|
||||
}
|
||||
if !reread {
|
||||
c.logger.Error().Err(err).Msg("Error creating user")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// User has been created meanwhile, re-read it to get the user id
|
||||
if reread {
|
||||
c.logger.Debug().Msg("User already exist, re-reading via libregraph")
|
||||
gureq := lgClient.UserApi.GetUser(newctx, newUser.GetOnPremisesSamAccountName())
|
||||
created, resp, err = gureq.Execute()
|
||||
defer resp.Body.Close()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error trying to re-read user from graphAPI")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cs3UserCreated := c.cs3UserFromLibregraph(newctx, created)
|
||||
|
||||
return &cs3UserCreated, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]any) error {
|
||||
newUser, err := c.libregraphUserFromClaims(claims)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Interface("claims", claims).Msg("Error converting claims to user")
|
||||
return fmt.Errorf("error converting claims to updated user: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user needs to be updated, only updates of "displayName" and "mail" are supported
|
||||
// currently.
|
||||
userupdate := libregraph.NewUserUpdate()
|
||||
switch {
|
||||
case newUser.GetDisplayName() != user.GetDisplayName():
|
||||
userupdate.SetDisplayName(newUser.GetDisplayName())
|
||||
case newUser.GetMail() != user.GetMail():
|
||||
userupdate.SetMail(newUser.GetMail())
|
||||
}
|
||||
|
||||
if userupdate.HasDisplayName() || userupdate.HasMail() {
|
||||
return c.updateLibregraphUser(user.GetId().GetOpaqueId(), *userupdate)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncGroupMemberships maintains a users group memberships based on an OIDC claim
|
||||
func (c cs3backend) SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]any) error {
|
||||
gatewayClient, err := c.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return err
|
||||
}
|
||||
newctx := context.Background()
|
||||
token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error getting token for service user")
|
||||
return err
|
||||
}
|
||||
|
||||
lgClient, err := c.setupLibregraphClient(newctx, token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error setting up libregraph client")
|
||||
return err
|
||||
}
|
||||
|
||||
lgUser, resp, err := lgClient.UserApi.GetUser(newctx, user.GetId().GetOpaqueId()).Expand([]string{"memberOf"}).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to lookup user via libregraph")
|
||||
return err
|
||||
}
|
||||
|
||||
currentGroups := lgUser.GetMemberOf()
|
||||
currentGroupSet := make(map[string]struct{})
|
||||
for _, group := range currentGroups {
|
||||
currentGroupSet[group.GetDisplayName()] = struct{}{}
|
||||
}
|
||||
|
||||
newGroupSet := make(map[string]struct{})
|
||||
if groups, ok := claims[c.autoProvisionClaims.Groups].([]any); ok {
|
||||
for _, g := range groups {
|
||||
if group, ok := g.(string); ok {
|
||||
newGroupSet[group] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group := range newGroupSet {
|
||||
if _, exists := currentGroupSet[group]; !exists {
|
||||
c.logger.Debug().Str("group", group).Msg("adding user to group")
|
||||
// Check if group exists
|
||||
lgGroup, err := c.getLibregraphGroup(newctx, lgClient, group)
|
||||
switch {
|
||||
case errors.Is(err, errGroupNotFound):
|
||||
newGroup := libregraph.Group{}
|
||||
newGroup.SetDisplayName(group)
|
||||
req := lgClient.GroupsApi.CreateGroup(newctx).Group(newGroup)
|
||||
var resp *http.Response
|
||||
lgGroup, resp, err = req.Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
// all good
|
||||
case resp == nil:
|
||||
return err
|
||||
default:
|
||||
// Ignore error if group already exists
|
||||
exists, lerr := c.isAlreadyExists(resp)
|
||||
switch {
|
||||
case lerr != nil:
|
||||
c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
|
||||
return err
|
||||
case !exists:
|
||||
c.logger.Error().Err(err).Msg("Failed to create group via libregraph")
|
||||
return err
|
||||
default:
|
||||
// group has been created meanwhile, re-read it to get the group id
|
||||
lgGroup, err = c.getLibregraphGroup(newctx, lgClient, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
memberref := "https://localhost/graph/v1.0/users/" + user.GetId().GetOpaqueId()
|
||||
resp, err := lgClient.GroupApi.AddMember(newctx, lgGroup.GetId()).MemberReference(
|
||||
libregraph.MemberReference{
|
||||
OdataId: &memberref,
|
||||
},
|
||||
).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to add user to group via libregraph")
|
||||
}
|
||||
}
|
||||
}
|
||||
for current := range currentGroupSet {
|
||||
if _, exists := newGroupSet[current]; !exists {
|
||||
c.logger.Debug().Str("group", current).Msg("deleting user from group")
|
||||
lgGroup, err := c.getLibregraphGroup(newctx, lgClient, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := lgClient.GroupApi.DeleteMember(newctx, lgGroup.GetId(), user.GetId().GetOpaqueId()).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c cs3backend) getLibregraphGroup(ctx context.Context, client *libregraph.APIClient, group string) (*libregraph.Group, error) {
|
||||
lgGroup, resp, err := client.GroupApi.GetGroup(ctx, group).Execute()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case resp == nil:
|
||||
return nil, err
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, errGroupNotFound
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return lgGroup, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) updateLibregraphUser(userid string, user libregraph.UserUpdate) error {
|
||||
gatewayClient, err := c.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return err
|
||||
}
|
||||
newctx := context.Background()
|
||||
token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error getting token for service user")
|
||||
return err
|
||||
}
|
||||
|
||||
lgClient, err := c.setupLibregraphClient(newctx, token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Error setting up libregraph client")
|
||||
return err
|
||||
}
|
||||
|
||||
req := lgClient.UserApi.UpdateUser(newctx, userid).UserUpdate(user)
|
||||
|
||||
_, resp, err := req.Execute()
|
||||
defer resp.Body.Close()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to update user via libregraph")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c cs3backend) setupLibregraphClient(_ context.Context, cs3token string) (*libregraph.APIClient, error) {
|
||||
// Use micro registry to resolve next graph service endpoint
|
||||
next, err := c.graphSelector.Select("qsfera.web.graph")
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("setupLibregraphClient: error during Select")
|
||||
return nil, err
|
||||
}
|
||||
node, err := next()
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("setupLibregraphClient: error getting next Node")
|
||||
return nil, err
|
||||
}
|
||||
lgconf := libregraph.NewConfiguration()
|
||||
lgconf.Servers = libregraph.ServerConfigurations{
|
||||
{
|
||||
URL: fmt.Sprintf("%s://%s/graph", node.Metadata["protocol"], node.Address),
|
||||
},
|
||||
}
|
||||
|
||||
lgconf.DefaultHeader = map[string]string{revactx.TokenHeader: cs3token}
|
||||
return libregraph.NewAPIClient(lgconf), nil
|
||||
}
|
||||
|
||||
func (c cs3backend) isAlreadyExists(resp *http.Response) (bool, error) {
|
||||
oDataErr := libregraph.NewOdataErrorWithDefaults()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("Error trying to read libregraph response")
|
||||
return false, err
|
||||
}
|
||||
err = json.Unmarshal(body, oDataErr)
|
||||
if err != nil {
|
||||
c.logger.Debug().Err(err).Msg("Error unmarshalling libregraph response")
|
||||
return false, err
|
||||
}
|
||||
|
||||
c.logger.Warn().Str("OData Error", oDataErr.Error.Message).Msg("Error Response")
|
||||
|
||||
if oDataErr.Error.Code == errorcode.NameAlreadyExists.String() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) libregraphUserFromClaims(claims map[string]any) (libregraph.User, error) {
|
||||
user := libregraph.User{}
|
||||
if dn, ok := claims[c.autoProvisionClaims.DisplayName].(string); ok {
|
||||
user.SetDisplayName(dn)
|
||||
} else {
|
||||
return user, fmt.Errorf("missing claim '%s' (displayName)", c.autoProvisionClaims.DisplayName)
|
||||
}
|
||||
if username, ok := claims[c.autoProvisionClaims.Username].(string); ok {
|
||||
user.SetOnPremisesSamAccountName(username)
|
||||
} else {
|
||||
return user, fmt.Errorf("missing claim '%s' (username)", c.autoProvisionClaims.Username)
|
||||
}
|
||||
// Email is optional so we don't need an 'else' here
|
||||
if mail, ok := claims[c.autoProvisionClaims.Email].(string); ok {
|
||||
user.SetMail(mail)
|
||||
}
|
||||
|
||||
sub, subExists := claims[oidc.Sub].(string)
|
||||
iss, issExists := claims[oidc.Iss].(string)
|
||||
|
||||
if subExists && issExists {
|
||||
var objectIdentity libregraph.ObjectIdentity
|
||||
objectIdentity.SetIssuer(iss)
|
||||
objectIdentity.SetIssuerAssignedId(sub)
|
||||
user.Identities = append(user.Identities, objectIdentity)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c cs3backend) cs3UserFromLibregraph(_ context.Context, lu *libregraph.User) cs3.User {
|
||||
cs3id := cs3.UserId{
|
||||
Type: cs3.UserType_USER_TYPE_PRIMARY,
|
||||
Idp: c.oidcISS,
|
||||
}
|
||||
|
||||
cs3id.OpaqueId = lu.GetId()
|
||||
|
||||
cs3user := cs3.User{
|
||||
Id: &cs3id,
|
||||
}
|
||||
cs3user.Username = lu.GetOnPremisesSamAccountName()
|
||||
cs3user.DisplayName = lu.GetDisplayName()
|
||||
cs3user.Mail = lu.GetMail()
|
||||
return cs3user
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewUserBackend creates a new instance of UserBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewUserBackend(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *UserBackend {
|
||||
mock := &UserBackend{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// UserBackend is an autogenerated mock type for the UserBackend type
|
||||
type UserBackend struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type UserBackend_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *UserBackend) EXPECT() *UserBackend_Expecter {
|
||||
return &UserBackend_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Authenticate provides a mock function for the type UserBackend
|
||||
func (_mock *UserBackend) Authenticate(ctx context.Context, username string, password string) (*userv1beta1.User, string, error) {
|
||||
ret := _mock.Called(ctx, username, password)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Authenticate")
|
||||
}
|
||||
|
||||
var r0 *userv1beta1.User
|
||||
var r1 string
|
||||
var r2 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*userv1beta1.User, string, error)); ok {
|
||||
return returnFunc(ctx, username, password)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *userv1beta1.User); ok {
|
||||
r0 = returnFunc(ctx, username, password)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*userv1beta1.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
|
||||
r1 = returnFunc(ctx, username, password)
|
||||
} else {
|
||||
r1 = ret.Get(1).(string)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
|
||||
r2 = returnFunc(ctx, username, password)
|
||||
} else {
|
||||
r2 = ret.Error(2)
|
||||
}
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// UserBackend_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
|
||||
type UserBackend_Authenticate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Authenticate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - username string
|
||||
// - password string
|
||||
func (_e *UserBackend_Expecter) Authenticate(ctx interface{}, username interface{}, password interface{}) *UserBackend_Authenticate_Call {
|
||||
return &UserBackend_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, username, password)}
|
||||
}
|
||||
|
||||
func (_c *UserBackend_Authenticate_Call) Run(run func(ctx context.Context, username string, password string)) *UserBackend_Authenticate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_Authenticate_Call) Return(user *userv1beta1.User, s string, err error) *UserBackend_Authenticate_Call {
|
||||
_c.Call.Return(user, s, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_Authenticate_Call) RunAndReturn(run func(ctx context.Context, username string, password string) (*userv1beta1.User, string, error)) *UserBackend_Authenticate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateUserFromClaims provides a mock function for the type UserBackend
|
||||
func (_mock *UserBackend) CreateUserFromClaims(ctx context.Context, claims map[string]any) (*userv1beta1.User, error) {
|
||||
ret := _mock.Called(ctx, claims)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateUserFromClaims")
|
||||
}
|
||||
|
||||
var r0 *userv1beta1.User
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, map[string]any) (*userv1beta1.User, error)); ok {
|
||||
return returnFunc(ctx, claims)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, map[string]any) *userv1beta1.User); ok {
|
||||
r0 = returnFunc(ctx, claims)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*userv1beta1.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, map[string]any) error); ok {
|
||||
r1 = returnFunc(ctx, claims)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UserBackend_CreateUserFromClaims_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUserFromClaims'
|
||||
type UserBackend_CreateUserFromClaims_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateUserFromClaims is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - claims map[string]any
|
||||
func (_e *UserBackend_Expecter) CreateUserFromClaims(ctx interface{}, claims interface{}) *UserBackend_CreateUserFromClaims_Call {
|
||||
return &UserBackend_CreateUserFromClaims_Call{Call: _e.mock.On("CreateUserFromClaims", ctx, claims)}
|
||||
}
|
||||
|
||||
func (_c *UserBackend_CreateUserFromClaims_Call) Run(run func(ctx context.Context, claims map[string]any)) *UserBackend_CreateUserFromClaims_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 map[string]any
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(map[string]any)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_CreateUserFromClaims_Call) Return(user *userv1beta1.User, err error) *UserBackend_CreateUserFromClaims_Call {
|
||||
_c.Call.Return(user, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_CreateUserFromClaims_Call) RunAndReturn(run func(ctx context.Context, claims map[string]any) (*userv1beta1.User, error)) *UserBackend_CreateUserFromClaims_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetUserByClaims provides a mock function for the type UserBackend
|
||||
func (_mock *UserBackend) GetUserByClaims(ctx context.Context, claim string, value string) (*userv1beta1.User, string, error) {
|
||||
ret := _mock.Called(ctx, claim, value)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetUserByClaims")
|
||||
}
|
||||
|
||||
var r0 *userv1beta1.User
|
||||
var r1 string
|
||||
var r2 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*userv1beta1.User, string, error)); ok {
|
||||
return returnFunc(ctx, claim, value)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *userv1beta1.User); ok {
|
||||
r0 = returnFunc(ctx, claim, value)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*userv1beta1.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) string); ok {
|
||||
r1 = returnFunc(ctx, claim, value)
|
||||
} else {
|
||||
r1 = ret.Get(1).(string)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(2).(func(context.Context, string, string) error); ok {
|
||||
r2 = returnFunc(ctx, claim, value)
|
||||
} else {
|
||||
r2 = ret.Error(2)
|
||||
}
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// UserBackend_GetUserByClaims_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByClaims'
|
||||
type UserBackend_GetUserByClaims_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetUserByClaims is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - claim string
|
||||
// - value string
|
||||
func (_e *UserBackend_Expecter) GetUserByClaims(ctx interface{}, claim interface{}, value interface{}) *UserBackend_GetUserByClaims_Call {
|
||||
return &UserBackend_GetUserByClaims_Call{Call: _e.mock.On("GetUserByClaims", ctx, claim, value)}
|
||||
}
|
||||
|
||||
func (_c *UserBackend_GetUserByClaims_Call) Run(run func(ctx context.Context, claim string, value string)) *UserBackend_GetUserByClaims_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_GetUserByClaims_Call) Return(user *userv1beta1.User, s string, err error) *UserBackend_GetUserByClaims_Call {
|
||||
_c.Call.Return(user, s, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_GetUserByClaims_Call) RunAndReturn(run func(ctx context.Context, claim string, value string) (*userv1beta1.User, string, error)) *UserBackend_GetUserByClaims_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SyncGroupMemberships provides a mock function for the type UserBackend
|
||||
func (_mock *UserBackend) SyncGroupMemberships(ctx context.Context, user *userv1beta1.User, claims map[string]any) error {
|
||||
ret := _mock.Called(ctx, user, claims)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SyncGroupMemberships")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
|
||||
r0 = returnFunc(ctx, user, claims)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// UserBackend_SyncGroupMemberships_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyncGroupMemberships'
|
||||
type UserBackend_SyncGroupMemberships_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// SyncGroupMemberships is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - user *userv1beta1.User
|
||||
// - claims map[string]any
|
||||
func (_e *UserBackend_Expecter) SyncGroupMemberships(ctx interface{}, user interface{}, claims interface{}) *UserBackend_SyncGroupMemberships_Call {
|
||||
return &UserBackend_SyncGroupMemberships_Call{Call: _e.mock.On("SyncGroupMemberships", ctx, user, claims)}
|
||||
}
|
||||
|
||||
func (_c *UserBackend_SyncGroupMemberships_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserBackend_SyncGroupMemberships_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *userv1beta1.User
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*userv1beta1.User)
|
||||
}
|
||||
var arg2 map[string]any
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(map[string]any)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_SyncGroupMemberships_Call) Return(err error) *UserBackend_SyncGroupMemberships_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_SyncGroupMemberships_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) error) *UserBackend_SyncGroupMemberships_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateUserIfNeeded provides a mock function for the type UserBackend
|
||||
func (_mock *UserBackend) UpdateUserIfNeeded(ctx context.Context, user *userv1beta1.User, claims map[string]any) error {
|
||||
ret := _mock.Called(ctx, user, claims)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateUserIfNeeded")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
|
||||
r0 = returnFunc(ctx, user, claims)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// UserBackend_UpdateUserIfNeeded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserIfNeeded'
|
||||
type UserBackend_UpdateUserIfNeeded_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpdateUserIfNeeded is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - user *userv1beta1.User
|
||||
// - claims map[string]any
|
||||
func (_e *UserBackend_Expecter) UpdateUserIfNeeded(ctx interface{}, user interface{}, claims interface{}) *UserBackend_UpdateUserIfNeeded_Call {
|
||||
return &UserBackend_UpdateUserIfNeeded_Call{Call: _e.mock.On("UpdateUserIfNeeded", ctx, user, claims)}
|
||||
}
|
||||
|
||||
func (_c *UserBackend_UpdateUserIfNeeded_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserBackend_UpdateUserIfNeeded_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *userv1beta1.User
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*userv1beta1.User)
|
||||
}
|
||||
var arg2 map[string]any
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(map[string]any)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_UpdateUserIfNeeded_Call) Return(err error) *UserBackend_UpdateUserIfNeeded_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserBackend_UpdateUserIfNeeded_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) error) *UserBackend_UpdateUserIfNeeded_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package userroles
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/settings/pkg/store/defaults"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"go-micro.dev/v4/metadata"
|
||||
)
|
||||
|
||||
type defaultRoleAssigner struct {
|
||||
Options
|
||||
}
|
||||
|
||||
// NewDefaultRoleAssigner returns an implementation of the UserRoleAssigner interface
|
||||
func NewDefaultRoleAssigner(opts ...Option) UserRoleAssigner {
|
||||
opt := Options{}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return defaultRoleAssigner{
|
||||
Options: opt,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUserRoleAssignment assigns the role "User" to the supplied user. Unless the user
|
||||
// already has a different role assigned.
|
||||
func (d defaultRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error) {
|
||||
var roleIDs []string
|
||||
if user.Id.Type != cs3.UserType_USER_TYPE_LIGHTWEIGHT {
|
||||
var err error
|
||||
roleIDs, err = loadRolesIDs(ctx, user.Id.OpaqueId, d.roleService)
|
||||
if err != nil {
|
||||
d.logger.Error().Err(err).Msg("Could not load roles")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(roleIDs) == 0 {
|
||||
// This user doesn't have a role assignment yet. Assign a
|
||||
// default user role. At least until proper roles are provided. See
|
||||
// https://github.com/owncloud/ocis/issues/1825 for more context.
|
||||
if user.Id.Type == cs3.UserType_USER_TYPE_PRIMARY || user.Id.Type == cs3.UserType_USER_TYPE_GUEST {
|
||||
roleId := defaults.BundleUUIDRoleUser
|
||||
if user.Id.Type == cs3.UserType_USER_TYPE_GUEST {
|
||||
roleId = defaults.BundleUUIDRoleGuest
|
||||
}
|
||||
d.logger.Info().Str("userid", user.Id.OpaqueId).Msg("user has no role assigned, assigning default user role")
|
||||
ctx = metadata.Set(ctx, middleware.AccountID, user.Id.OpaqueId)
|
||||
_, err := d.roleService.AssignRoleToUser(ctx, &settingssvc.AssignRoleToUserRequest{
|
||||
AccountUuid: user.Id.OpaqueId,
|
||||
RoleId: roleId,
|
||||
})
|
||||
if err != nil {
|
||||
d.logger.Error().Err(err).Msg("Could not add default role")
|
||||
return nil, err
|
||||
}
|
||||
roleIDs = append(roleIDs, roleId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ApplyUserRole it looks up the user's role in the settings service and adds it
|
||||
// user's opaque data
|
||||
func (d defaultRoleAssigner) ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error) {
|
||||
roleIDs, err := loadRolesIDs(ctx, user.Id.OpaqueId, d.roleService)
|
||||
if err != nil {
|
||||
d.logger.Error().Err(err).Msg("Could not load roles")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewUserRoleAssigner creates a new instance of UserRoleAssigner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewUserRoleAssigner(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *UserRoleAssigner {
|
||||
mock := &UserRoleAssigner{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// UserRoleAssigner is an autogenerated mock type for the UserRoleAssigner type
|
||||
type UserRoleAssigner struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type UserRoleAssigner_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *UserRoleAssigner) EXPECT() *UserRoleAssigner_Expecter {
|
||||
return &UserRoleAssigner_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// ApplyUserRole provides a mock function for the type UserRoleAssigner
|
||||
func (_mock *UserRoleAssigner) ApplyUserRole(ctx context.Context, user *userv1beta1.User) (*userv1beta1.User, error) {
|
||||
ret := _mock.Called(ctx, user)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ApplyUserRole")
|
||||
}
|
||||
|
||||
var r0 *userv1beta1.User
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User) (*userv1beta1.User, error)); ok {
|
||||
return returnFunc(ctx, user)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User) *userv1beta1.User); ok {
|
||||
r0 = returnFunc(ctx, user)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*userv1beta1.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *userv1beta1.User) error); ok {
|
||||
r1 = returnFunc(ctx, user)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UserRoleAssigner_ApplyUserRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyUserRole'
|
||||
type UserRoleAssigner_ApplyUserRole_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ApplyUserRole is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - user *userv1beta1.User
|
||||
func (_e *UserRoleAssigner_Expecter) ApplyUserRole(ctx interface{}, user interface{}) *UserRoleAssigner_ApplyUserRole_Call {
|
||||
return &UserRoleAssigner_ApplyUserRole_Call{Call: _e.mock.On("ApplyUserRole", ctx, user)}
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_ApplyUserRole_Call) Run(run func(ctx context.Context, user *userv1beta1.User)) *UserRoleAssigner_ApplyUserRole_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *userv1beta1.User
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*userv1beta1.User)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_ApplyUserRole_Call) Return(user1 *userv1beta1.User, err error) *UserRoleAssigner_ApplyUserRole_Call {
|
||||
_c.Call.Return(user1, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_ApplyUserRole_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User) (*userv1beta1.User, error)) *UserRoleAssigner_ApplyUserRole_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateUserRoleAssignment provides a mock function for the type UserRoleAssigner
|
||||
func (_mock *UserRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *userv1beta1.User, claims map[string]any) (*userv1beta1.User, error) {
|
||||
ret := _mock.Called(ctx, user, claims)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateUserRoleAssignment")
|
||||
}
|
||||
|
||||
var r0 *userv1beta1.User
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) (*userv1beta1.User, error)); ok {
|
||||
return returnFunc(ctx, user, claims)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *userv1beta1.User, map[string]any) *userv1beta1.User); ok {
|
||||
r0 = returnFunc(ctx, user, claims)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*userv1beta1.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *userv1beta1.User, map[string]any) error); ok {
|
||||
r1 = returnFunc(ctx, user, claims)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UserRoleAssigner_UpdateUserRoleAssignment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserRoleAssignment'
|
||||
type UserRoleAssigner_UpdateUserRoleAssignment_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpdateUserRoleAssignment is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - user *userv1beta1.User
|
||||
// - claims map[string]any
|
||||
func (_e *UserRoleAssigner_Expecter) UpdateUserRoleAssignment(ctx interface{}, user interface{}, claims interface{}) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
|
||||
return &UserRoleAssigner_UpdateUserRoleAssignment_Call{Call: _e.mock.On("UpdateUserRoleAssignment", ctx, user, claims)}
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) Run(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any)) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *userv1beta1.User
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*userv1beta1.User)
|
||||
}
|
||||
var arg2 map[string]any
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(map[string]any)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) Return(user1 *userv1beta1.User, err error) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
|
||||
_c.Call.Return(user1, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UserRoleAssigner_UpdateUserRoleAssignment_Call) RunAndReturn(run func(ctx context.Context, user *userv1beta1.User, claims map[string]any) (*userv1beta1.User, error)) *UserRoleAssigner_UpdateUserRoleAssignment_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package userroles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/oidc"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"go-micro.dev/v4/metadata"
|
||||
)
|
||||
|
||||
type oidcRoleAssigner struct {
|
||||
Options
|
||||
}
|
||||
|
||||
// NewOIDCRoleAssigner returns an implementation of the UserRoleAssigner interface
|
||||
func NewOIDCRoleAssigner(opts ...Option) UserRoleAssigner {
|
||||
opt := Options{}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return oidcRoleAssigner{
|
||||
Options: opt,
|
||||
}
|
||||
}
|
||||
|
||||
func extractRoles(rolesClaim string, claims map[string]any) (map[string]struct{}, error) {
|
||||
|
||||
claimRoles := map[string]struct{}{}
|
||||
// happy path
|
||||
value, _ := claims[rolesClaim].(string)
|
||||
if value != "" {
|
||||
claimRoles[value] = struct{}{}
|
||||
return claimRoles, nil
|
||||
}
|
||||
|
||||
claim, err := oidc.WalkSegments(oidc.SplitWithEscaping(rolesClaim, ".", "\\"), claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch v := claim.(type) {
|
||||
case []string:
|
||||
for _, cr := range v {
|
||||
claimRoles[cr] = struct{}{}
|
||||
}
|
||||
case []any:
|
||||
for _, cri := range v {
|
||||
cr, ok := cri.(string)
|
||||
if !ok {
|
||||
err := errors.New("invalid role in claims")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claimRoles[cr] = struct{}{}
|
||||
}
|
||||
case string:
|
||||
claimRoles[v] = struct{}{}
|
||||
default:
|
||||
return nil, errors.New("no roles in user claims")
|
||||
}
|
||||
|
||||
return claimRoles, nil
|
||||
}
|
||||
|
||||
// UpdateUserRoleAssignment assigns the role "User" to the supplied user. Unless the user
|
||||
// already has a different role assigned.
|
||||
func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error) {
|
||||
logger := ra.logger.SubloggerWithRequestID(ctx).With().Str("userid", user.GetId().GetOpaqueId()).Logger()
|
||||
roleNamesToRoleIDs, err := ra.roleNamesToRoleIDs()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Error mapping role names to role ids")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claimRoles, err := extractRoles(ra.rolesClaim, claims)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Error mapping role names to role ids")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(claimRoles) == 0 {
|
||||
err := errors.New("no roles set in claim")
|
||||
logger.Error().Err(err).Msg("")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// the roleMapping config is supposed to have the role mappings ordered from the highest privileged role
|
||||
// down to the lowest privileged role. Since КуСфера currently only can handle a single role assignment we
|
||||
// pick the highest privileged role that matches a value from the claims
|
||||
roleIDFromClaim := ""
|
||||
for _, mapping := range ra.Options.roleMapping {
|
||||
if _, ok := claimRoles[mapping.ClaimValue]; ok {
|
||||
logger.Debug().Str("qsferaRole", mapping.RoleName).Str("role id", roleNamesToRoleIDs[mapping.RoleName]).Msg("first matching role")
|
||||
roleIDFromClaim = roleNamesToRoleIDs[mapping.RoleName]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if roleIDFromClaim == "" {
|
||||
err := errors.New("no role in claim maps to an КуСфера role")
|
||||
logger.Error().Err(err).Msg("")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assignedRoles, err := loadRolesIDs(ctx, user.GetId().GetOpaqueId(), ra.roleService)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Could not load roles")
|
||||
return nil, err
|
||||
}
|
||||
if len(assignedRoles) > 1 {
|
||||
logger.Error().Str("userID", user.GetId().GetOpaqueId()).Int("numRoles", len(assignedRoles)).Msg("The user has too many roles assigned")
|
||||
}
|
||||
logger.Debug().Interface("assignedRoleIds", assignedRoles).Msg("Currently assigned roles")
|
||||
|
||||
if len(assignedRoles) != 1 || (assignedRoles[0] != roleIDFromClaim) {
|
||||
logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleId", roleIDFromClaim).Msg("Updating role assignment for user")
|
||||
newctx, err := ra.prepareAdminContext()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Error creating admin context")
|
||||
return nil, err
|
||||
}
|
||||
if _, err = ra.roleService.AssignRoleToUser(newctx, &settingssvc.AssignRoleToUserRequest{
|
||||
AccountUuid: user.GetId().GetOpaqueId(),
|
||||
RoleId: roleIDFromClaim,
|
||||
}); err != nil {
|
||||
logger.Error().Err(err).Msg("Role assignment failed")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", []string{roleIDFromClaim})
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ApplyUserRole it looks up the user's role in the settings service and adds it
|
||||
// user's opaque data
|
||||
func (ra oidcRoleAssigner) ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error) {
|
||||
roleIDs, err := loadRolesIDs(ctx, user.Id.OpaqueId, ra.roleService)
|
||||
if err != nil {
|
||||
ra.logger.Error().Err(err).Msg("Could not load roles")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDs)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (ra oidcRoleAssigner) prepareAdminContext() (context.Context, error) {
|
||||
gatewayClient, err := ra.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
ra.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return nil, err
|
||||
}
|
||||
newctx, err := utils.GetServiceUserContext(ra.serviceAccount.ServiceAccountID, gatewayClient, ra.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
ra.logger.Error().Err(err).Msg("Error preparing request context for provisioning role assignments.")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newctx = metadata.Set(newctx, middleware.AccountID, ra.serviceAccount.ServiceAccountID)
|
||||
return newctx, nil
|
||||
}
|
||||
|
||||
type roleNameToIDCache struct {
|
||||
roleNameToID map[string]string
|
||||
lastRead time.Time
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
var roleNameToID roleNameToIDCache
|
||||
|
||||
func (ra oidcRoleAssigner) roleNamesToRoleIDs() (map[string]string, error) {
|
||||
cacheTTL := 5 * time.Minute
|
||||
roleNameToID.lock.RLock()
|
||||
|
||||
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
|
||||
defer roleNameToID.lock.RUnlock()
|
||||
return roleNameToID.roleNameToID, nil
|
||||
}
|
||||
ra.logger.Debug().Msg("refreshing roles ids")
|
||||
|
||||
// cache needs Refresh get a write lock
|
||||
roleNameToID.lock.RUnlock()
|
||||
roleNameToID.lock.Lock()
|
||||
defer roleNameToID.lock.Unlock()
|
||||
|
||||
// check again, another goroutine might have updated while we "upgraded" the lock
|
||||
if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
|
||||
return roleNameToID.roleNameToID, nil
|
||||
}
|
||||
|
||||
// Get all roles to find the role IDs.
|
||||
// To list roles we need some elevated access to the settings service
|
||||
// prepare a new request context for that until we have service accounts
|
||||
ctx, err := ra.prepareAdminContext()
|
||||
if err != nil {
|
||||
ra.logger.Error().Err(err).Msg("Error creating admin context")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := &settingssvc.ListBundlesRequest{}
|
||||
res, err := ra.roleService.ListRoles(ctx, req)
|
||||
if err != nil {
|
||||
ra.logger.Error().Err(err).Msg("Failed to list all roles")
|
||||
return map[string]string{}, err
|
||||
}
|
||||
|
||||
newIDs := map[string]string{}
|
||||
for _, role := range res.Bundles {
|
||||
ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role")
|
||||
newIDs[role.Name] = role.Id
|
||||
}
|
||||
ra.logger.Debug().Interface("roleMap", newIDs).Msg("Role Name to role ID map")
|
||||
roleNameToID.roleNameToID = newIDs
|
||||
roleNameToID.lastRead = time.Now()
|
||||
return roleNameToID.roleNameToID, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package userroles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractRolesArray(t *testing.T) {
|
||||
byt := []byte(`{"roles":["a","b"]}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("roles", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := roles["a"]; !ok {
|
||||
t.Fatal("must contain 'a'")
|
||||
}
|
||||
if _, ok := roles["b"]; !ok {
|
||||
t.Fatal("must contain 'b'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesString(t *testing.T) {
|
||||
byt := []byte(`{"roles":"a"}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("roles", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := roles["a"]; !ok {
|
||||
t.Fatal("must contain 'a'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesPathArray(t *testing.T) {
|
||||
byt := []byte(`{"sub":{"roles":["a","b"]}}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("sub.roles", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := roles["a"]; !ok {
|
||||
t.Fatal("must contain 'a'")
|
||||
}
|
||||
if _, ok := roles["b"]; !ok {
|
||||
t.Fatal("must contain 'b'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesPathString(t *testing.T) {
|
||||
byt := []byte(`{"sub":{"roles":"a"}}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("sub.roles", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := roles["a"]; !ok {
|
||||
t.Fatal("must contain 'a'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEscapedRolesPathString(t *testing.T) {
|
||||
byt := []byte(`{"sub.roles":"a"}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("sub\\.roles", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := roles["a"]; !ok {
|
||||
t.Fatal("must contain 'a'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRoles(t *testing.T) {
|
||||
byt := []byte(`{"sub":{"foo":"a"}}`)
|
||||
|
||||
claims := map[string]any{}
|
||||
err := json.Unmarshal(byt, &claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := extractRoles("sub.roles", claims)
|
||||
if err == nil {
|
||||
t.Fatal("must not find a role")
|
||||
}
|
||||
if len(roles) != 0 {
|
||||
t.Fatal("length of roles mut be 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package userroles
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/proxy/pkg/config"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
// UserRoleAssigner allows providing different implementations for how users get their default roles
|
||||
// assigned by the proxy during authentication
|
||||
type UserRoleAssigner interface {
|
||||
// UpdateUserRoleAssignment is called by the account resolver middleware. It updates the user's role assignment
|
||||
// based on the user's (OIDC) claims. It adds the user's roles to the opaque data of the cs3.User struct
|
||||
UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]any) (*cs3.User, error)
|
||||
// ApplyUserRole can be called by proxy middlewares, it looks up the user's roles and adds them
|
||||
// the users "roles" key in the user's opaque data
|
||||
ApplyUserRole(ctx context.Context, user *cs3.User) (*cs3.User, error)
|
||||
}
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
roleService settingssvc.RoleService
|
||||
rolesClaim string
|
||||
roleMapping []config.RoleMapping
|
||||
serviceAccount config.ServiceAccount
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// WithLogger configure the logger
|
||||
func WithLogger(l log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// WithRoleService sets the roleservice instance to use
|
||||
func WithRoleService(rs settingssvc.RoleService) Option {
|
||||
return func(o *Options) {
|
||||
o.roleService = rs
|
||||
}
|
||||
}
|
||||
|
||||
// WithRolesClaim sets the OIDC claim for looking up role names
|
||||
func WithRolesClaim(claim string) Option {
|
||||
return func(o *Options) {
|
||||
o.rolesClaim = claim
|
||||
}
|
||||
}
|
||||
|
||||
// WithRoleMapping configures the map of КуСфера role names to claims values
|
||||
func WithRoleMapping(roleMap []config.RoleMapping) Option {
|
||||
return func(o *Options) {
|
||||
o.roleMapping = roleMap
|
||||
}
|
||||
}
|
||||
|
||||
// WithRevaGatewaySelector set the gatewaySelector option
|
||||
func WithRevaGatewaySelector(selectable pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.gatewaySelector = selectable
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceAccount configures the service account creator to use
|
||||
func WithServiceAccount(c config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.serviceAccount = c
|
||||
}
|
||||
}
|
||||
|
||||
// loadRolesIDs returns the role-ids assigned to an user
|
||||
func loadRolesIDs(ctx context.Context, opaqueUserID string, rs settingssvc.RoleService) ([]string, error) {
|
||||
req := &settingssvc.ListRoleAssignmentsRequest{AccountUuid: opaqueUserID}
|
||||
assignmentResponse, err := rs.ListRoleAssignments(ctx, req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleIDs := make([]string, 0)
|
||||
|
||||
for _, assignment := range assignmentResponse.Assignments {
|
||||
roleIDs = append(roleIDs, assignment.RoleId)
|
||||
}
|
||||
|
||||
return roleIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type code int
|
||||
|
||||
const (
|
||||
// SabredavBadRequest maps to HTTP 400
|
||||
SabredavBadRequest code = iota
|
||||
// SabredavMethodNotAllowed maps to HTTP 405
|
||||
SabredavMethodNotAllowed
|
||||
// SabredavNotAuthenticated maps to HTTP 401
|
||||
SabredavNotAuthenticated
|
||||
// SabredavPreconditionFailed maps to HTTP 412
|
||||
SabredavPreconditionFailed
|
||||
// SabredavPermissionDenied maps to HTTP 403
|
||||
SabredavPermissionDenied
|
||||
// SabredavNotFound maps to HTTP 404
|
||||
SabredavNotFound
|
||||
// SabredavConflict maps to HTTP 409
|
||||
SabredavConflict
|
||||
)
|
||||
|
||||
var (
|
||||
codesEnum = []string{
|
||||
"Sabre\\DAV\\Exception\\BadRequest",
|
||||
"Sabre\\DAV\\Exception\\MethodNotAllowed",
|
||||
"Sabre\\DAV\\Exception\\NotAuthenticated",
|
||||
"Sabre\\DAV\\Exception\\PreconditionFailed",
|
||||
"Sabre\\DAV\\Exception\\PermissionDenied",
|
||||
"Sabre\\DAV\\Exception\\NotFound",
|
||||
"Sabre\\DAV\\Exception\\Conflict",
|
||||
}
|
||||
)
|
||||
|
||||
type Exception struct {
|
||||
Code code
|
||||
Message string
|
||||
Header string
|
||||
}
|
||||
|
||||
// Marshal just calls the xml marshaller for a given Exception.
|
||||
func Marshal(e Exception) ([]byte, error) {
|
||||
xmlstring, err := xml.Marshal(&errorXML{
|
||||
Xmlnsd: "DAV",
|
||||
Xmlnss: "http://sabredav.org/ns",
|
||||
Exception: codesEnum[e.Code],
|
||||
Message: e.Message,
|
||||
Header: e.Header,
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(""), err
|
||||
}
|
||||
return []byte(xml.Header + string(xmlstring)), err
|
||||
}
|
||||
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
|
||||
type errorXML struct {
|
||||
XMLName xml.Name `xml:"d:error"`
|
||||
Xmlnsd string `xml:"xmlns:d,attr"`
|
||||
Xmlnss string `xml:"xmlns:s,attr"`
|
||||
Exception string `xml:"s:Exception"`
|
||||
Message string `xml:"s:Message"`
|
||||
InnerXML []byte `xml:",innerxml"`
|
||||
Header string `xml:"s:Header,omitempty"`
|
||||
}
|
||||
|
||||
func HandleWebdavError(w http.ResponseWriter, b []byte, err error) {
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package webdav
|
||||
|
||||
import "net/http"
|
||||
|
||||
var methods = []string{"PROPFIND", "DELETE", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"}
|
||||
|
||||
// This is a non exhaustive way to detect if a request is directed to a webdav server. This naïve implementation
|
||||
// only deals with the set of methods exclusive to WebDAV. Since WebDAV is a superset of HTTP, GET, POST and so on
|
||||
// are valid methods, but this implementation would require a larger effort than we can build upon in this file.
|
||||
// This is needed because the proxy might need to create a response with a webdav body; such as unauthorized.
|
||||
func IsWebdavRequest(r *http.Request) bool {
|
||||
for i := range methods {
|
||||
if methods[i] == r.Method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user