mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-07 11:21:56 -04:00
Merge pull request #3609 from maki5/feat/guestauth_service
feat(guestlinks)!: auth-guest service
This commit is contained in:
53 files changed
+3298
-17
No files matched your search
+1
-1
@@ -2482,6 +2482,7 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e
|
||||
"OC_TRANSLATION_PATH": "%s/tests/config/translations" % dirs["base"],
|
||||
"ACTIVITYLOG_WRITE_BUFFER_DURATION": "0", # Disable write buffer so that test expectations are met in time
|
||||
"OC_LDAP_LOOKUP_CACHE_TTL": "0", # disable ldap lookup cache so that test fixture change are applied right away
|
||||
"OC_ENABLE_GUEST_LINKS": True,
|
||||
# search grpc port needed for index cli tests
|
||||
"SEARCH_GRPC_ADDR": "0.0.0.0:9220",
|
||||
# debug addresses required for running services health tests
|
||||
@@ -2521,7 +2522,6 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e
|
||||
"WEBFINGER_DEBUG_ADDR": "0.0.0.0:9279",
|
||||
"STORAGE_USERS_POSIX_SCAN_DEBOUNCE_DELAY": 0,
|
||||
"OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY,
|
||||
"GRAPH_ENABLE_GUEST_INVITES": True,
|
||||
}
|
||||
|
||||
if storage == "posix":
|
||||
|
||||
@@ -30,6 +30,7 @@ OC_MODULES = \
|
||||
services/auth-app \
|
||||
services/auth-basic \
|
||||
services/auth-bearer \
|
||||
services/auth-guest \
|
||||
services/auth-machine \
|
||||
services/auth-service \
|
||||
services/clientlog \
|
||||
|
||||
@@ -39,6 +39,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-tika v0.3.1
|
||||
github.com/google/renameio/v2 v2.0.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gookit/config/v2 v2.2.9
|
||||
github.com/gorilla/mux v1.8.1
|
||||
@@ -235,7 +236,6 @@ require (
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||
github.com/google/renameio/v2 v2.0.2 // indirect
|
||||
github.com/gookit/goutil v0.8.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/command"
|
||||
authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/command"
|
||||
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command"
|
||||
authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command"
|
||||
clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command"
|
||||
@@ -138,6 +139,11 @@ var serviceCommands = []register.Command{
|
||||
cfg.Groups.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
func(cfg *config.Config) *cobra.Command {
|
||||
return ServiceCommand(cfg, cfg.AuthGuest.Service.Name, authguest.GetCommands(cfg.AuthGuest), func(c *config.Config) {
|
||||
cfg.AuthGuest.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
func(cfg *config.Config) *cobra.Command {
|
||||
return ServiceCommand(cfg, cfg.IDM.Service.Name, idm.GetCommands(cfg.IDM), func(c *config.Config) {
|
||||
cfg.IDM.Commons = cfg.Commons
|
||||
|
||||
@@ -69,6 +69,7 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
|
||||
idmServicePassword, idpServicePassword, ocAdminServicePassword, revaServicePassword string
|
||||
tokenManagerJwtSecret, collaborationWOPISecret, machineAuthAPIKey, systemUserAPIKey string
|
||||
revaTransferSecret, thumbnailsTransferSecret, serviceAccountSecret, urlSigningSecret string
|
||||
authGuestJWTSecret string
|
||||
adminPasswdwordGenerated bool
|
||||
)
|
||||
|
||||
@@ -103,6 +104,13 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
|
||||
return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err)
|
||||
}
|
||||
}
|
||||
authGuestJWTSecret = oldCfg.AuthGuest.TokenManager.JWTSecret
|
||||
if authGuestJWTSecret == "" {
|
||||
authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
systemUserID = uuid.NewString()
|
||||
adminUserID = uuid.NewString()
|
||||
@@ -155,6 +163,10 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate random secret for urlSigningSecret: %s", err)
|
||||
}
|
||||
authGuestJWTSecret, err = generators.GenerateRandomPassword(passwordLength)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate random secret for authGuestJWTSecret: %s", err)
|
||||
}
|
||||
thumbnailsTransferSecret, err = generators.GenerateRandomPassword(passwordLength)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate random password for thumbnailsTransferSecret: %s", err)
|
||||
@@ -212,6 +224,10 @@ func CreateConfig(insecure, forceOverwrite, diff bool, configPath, adminPassword
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthGuest: AuthGuest{
|
||||
ServiceAccount: serviceAccount,
|
||||
TokenManager: TokenManager{JWTSecret: authGuestJWTSecret},
|
||||
},
|
||||
Users: UsersAndGroupsService{
|
||||
Drivers: LdapBasedService{
|
||||
Ldap: LdapSettings{
|
||||
|
||||
@@ -32,6 +32,7 @@ type OpenCloudConfig struct {
|
||||
AuthBearer AuthbearerService `yaml:"auth_bearer"`
|
||||
Users UsersAndGroupsService `yaml:"users"`
|
||||
Groups UsersAndGroupsService `yaml:"groups"`
|
||||
AuthGuest AuthGuest `yaml:"auth_guest"`
|
||||
Ocm OcmService `yaml:"ocm"`
|
||||
Thumbnails ThumbnailService `yaml:"thumbnails"`
|
||||
Search Search `yaml:"search"`
|
||||
@@ -53,6 +54,12 @@ type Activitylog struct {
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
}
|
||||
|
||||
// AuthGuest is the configuration for the auth-guest service
|
||||
type AuthGuest struct {
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
TokenManager TokenManager `yaml:"token_manager"`
|
||||
}
|
||||
|
||||
// App is the configuration for the collaboration service
|
||||
type App struct {
|
||||
Insecure bool `yaml:"insecure"`
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command"
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
|
||||
authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/command"
|
||||
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command"
|
||||
authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/command"
|
||||
clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/command"
|
||||
@@ -198,6 +199,13 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) {
|
||||
cfg.Groups.Commons = cfg.Commons
|
||||
return groups.Execute(cfg.Groups)
|
||||
})
|
||||
if opts.Config.Commons != nil && opts.Config.Commons.EnableGuestLinks {
|
||||
reg(3, opts.Config.AuthGuest.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
|
||||
cfg.AuthGuest.Context = ctx
|
||||
cfg.AuthGuest.Commons = cfg.Commons
|
||||
return authguest.Execute(cfg.AuthGuest)
|
||||
})
|
||||
}
|
||||
reg(3, opts.Config.IDM.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
|
||||
cfg.IDM.Context = ctx
|
||||
cfg.IDM.Commons = cfg.Commons
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config"
|
||||
authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config"
|
||||
authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config"
|
||||
clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config"
|
||||
@@ -99,6 +100,7 @@ type Config struct {
|
||||
Gateway *gateway.Config `yaml:"gateway"`
|
||||
Graph *graph.Config `yaml:"graph"`
|
||||
Groups *groups.Config `yaml:"groups"`
|
||||
AuthGuest *authguest.Config `yaml:"auth_guest"`
|
||||
IDM *idm.Config `yaml:"idm"`
|
||||
IDP *idp.Config `yaml:"idp"`
|
||||
Invitations *invitations.Config `yaml:"invitations"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config/defaults"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config/defaults"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config/defaults"
|
||||
authguest "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/defaults"
|
||||
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/config/defaults"
|
||||
authservice "github.com/opencloud-eu/opencloud/services/auth-service/pkg/config/defaults"
|
||||
clientlog "github.com/opencloud-eu/opencloud/services/clientlog/pkg/config/defaults"
|
||||
@@ -74,6 +75,7 @@ func DefaultConfig() *Config {
|
||||
Gateway: gateway.DefaultConfig(),
|
||||
Graph: graph.DefaultConfig(),
|
||||
Groups: groups.DefaultConfig(),
|
||||
AuthGuest: authguest.DefaultConfig(),
|
||||
IDM: idm.DefaultConfig(),
|
||||
IDP: idp.DefaultConfig(),
|
||||
Invitations: invitations.DefaultConfig(),
|
||||
|
||||
@@ -58,15 +58,15 @@ func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.Reva == nil {
|
||||
cfg.Reva = &shared.Reva{}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCommons copies applicable parts of the OpenCloud config into the commons part
|
||||
func EnsureCommons(cfg *config.Config) {
|
||||
// ensure the commons part is initialized
|
||||
if cfg.Commons == nil {
|
||||
cfg.Commons = &shared.Commons{}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCommons copies applicable parts of the OpenCloud config into the commons part
|
||||
func EnsureCommons(cfg *config.Config) {
|
||||
cfg.Commons.Log = structs.CopyOrZeroValue(cfg.Log)
|
||||
cfg.Commons.Cache = structs.CopyOrZeroValue(cfg.Cache)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
)
|
||||
|
||||
@@ -20,3 +21,19 @@ func (ResourceMention) Unmarshal(v []byte) (interface{}, error) {
|
||||
err := json.Unmarshal(v, &e)
|
||||
return e, err
|
||||
}
|
||||
|
||||
type GuestTokenCreated struct {
|
||||
ShareID *collaboration.ShareId
|
||||
Sharer *user.UserId
|
||||
GranteeEmail string
|
||||
ItemID *provider.ResourceId
|
||||
ResourceName string
|
||||
Token string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func (GuestTokenCreated) Unmarshal(v []byte) (interface{}, error) {
|
||||
e := GuestTokenCreated{}
|
||||
err := json.Unmarshal(v, &e)
|
||||
return e, err
|
||||
}
|
||||
@@ -80,6 +80,7 @@ type Commons struct {
|
||||
SystemUserAPIKey string `mask:"password" yaml:"system_user_api_key" env:"SYSTEM_USER_API_KEY" desc:"API key for all system users." introductionVersion:"1.0.0"`
|
||||
AdminUserID string `yaml:"admin_user_id" env:"OC_ADMIN_USER_ID" desc:"ID of a user, that should receive admin privileges. Consider that the UUID can be encoded in some LDAP deployment configurations like in .ldif files. These need to be decoded beforehand." introductionVersion:"1.0.0"`
|
||||
MultiTenantEnabled bool `yaml:"multi_tenant_enabled" env:"OC_MULTI_TENANT_ENABLED" desc:"Set this to true to enable multi-tenant support." introductionVersion:"4.0.0"`
|
||||
EnableGuestLinks bool `yaml:"enable_guest_links" env:"OC_ENABLE_GUEST_LINKS" desc:"Enables the guest links feature: creating shares to mail addresses and redeeming guest links. Disabled by default." introductionVersion:"%%NEXT%%"`
|
||||
|
||||
// NOTE: you will not fing GRPCMaxReceivedMessageSize size being used in the code. The envvar is actually extracted in revas `pool` package: https://github.com/cs3org/reva/blob/edge/pkg/rgrpc/todo/pool/connection.go
|
||||
// It is mentioned here again so it is documented
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# maintain v2 separate mocks dir
|
||||
dir: "{{.InterfaceDir}}/mocks"
|
||||
structname: "{{.InterfaceName}}"
|
||||
filename: "{{.InterfaceName | snakecase }}.go"
|
||||
pkgname: mocks
|
||||
|
||||
template: testify
|
||||
packages:
|
||||
github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest:
|
||||
interfaces:
|
||||
AuthGuest: {}
|
||||
github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage:
|
||||
interfaces:
|
||||
Manager: {}
|
||||
@@ -0,0 +1,15 @@
|
||||
SHELL := bash
|
||||
NAME := auth-guest
|
||||
|
||||
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,101 @@
|
||||
# auth-guest
|
||||
|
||||
The `auth-guest` service gives guest users access to a share without a full
|
||||
OpenCloud account. When a share is created for a user of type
|
||||
`USER_TYPE_GUEST`, the service issues a one-time guest link token; redeeming
|
||||
that token exchanges it for a signed session cookie that authenticates the
|
||||
guest.
|
||||
|
||||
It is disabled by default. Set `OC_ENABLE_GUEST_LINKS=true` to enable the guest
|
||||
links feature and start the service.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Consumes** the share lifecycle events `ShareCreated`, `ShareRemoved` and
|
||||
`ShareExpired`.
|
||||
- **Publishes** the `GuestTokenCreated` event carrying the guest link token,
|
||||
so the link can be delivered to the guest.
|
||||
- Exposes an unauthenticated endpoint that redeems the token and sets a
|
||||
session cookie.
|
||||
- Stores only hashes of the token and deletes the stored record when the share
|
||||
is removed or expires.
|
||||
|
||||
## Guest links flow
|
||||
|
||||
The following sequence diagram describes the guest links flow:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Guest user
|
||||
participant Web as Web client
|
||||
participant Redeem as Redeem endpoint
|
||||
participant Proxy as OpenCloud proxy
|
||||
participant Graph as Graph / sharedWithMe
|
||||
participant DAV as WebDAV
|
||||
participant Reva as Reva
|
||||
|
||||
User->>Web: Open guest link with valid token
|
||||
Web->>+Redeem: Redeem Token
|
||||
Note right of Redeem: Validate Token
|
||||
Redeem->>+Reva: Get Share
|
||||
Reva->>-Redeem: Share
|
||||
Note right of Redeem: Validate Share, Mark Token used
|
||||
Redeem->>-Web: Set Cookie, return shareid
|
||||
Note right of Web: HTTP only cookie with signed JWT (JWT lifetime 24h)
|
||||
Web->>+Proxy: "/graph/me/drives/sharedWithMe"
|
||||
Proxy->>+Reva: validate token extracted from JWT
|
||||
Note right of Reva: Sign Reva Token for Guest User
|
||||
Reva->>-Proxy: Authenticated
|
||||
Proxy->>+Graph: "/graph/me/drives/sharedWithMe"
|
||||
Note right of Proxy: Using Reva Token
|
||||
Graph->>+Reva: Requests to ShareProvider
|
||||
Reva->>-Graph: Shares
|
||||
Graph->>-Proxy: driveItems (all shares for the Guest User)
|
||||
Proxy->>-Web: driveItems
|
||||
Note right of Web: Extracts driveItem for the specific share
|
||||
Web->>+Proxy: PROPFIND (resource id extracted from driveItem)
|
||||
Note right of Web: Using Cookie
|
||||
Proxy->>+Reva: validate token extracted from JWT
|
||||
Note right of Reva: Sign Reva Token for Guest User
|
||||
Reva->>-Proxy: Authenticated
|
||||
Proxy->>+DAV: PROPFIND
|
||||
Note right of Proxy: Using Reva Token
|
||||
DAV->>+Reva: Requests to StorageProvider
|
||||
Reva->>-DAV: StorageProvider Responses
|
||||
DAV->>-Proxy: PROPFIND Response
|
||||
Proxy->>-Web: PROPFIND Response
|
||||
```
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
1. **Issue** — on the consumed `ShareCreated` event, where the grantee is a
|
||||
guest, the service generates a random secret and stores a record keyed by
|
||||
the hash of the share id. It then publishes the `GuestTokenCreated` event
|
||||
with the token.
|
||||
2. **Redeem** — the guest posts the token to
|
||||
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/redeem`.
|
||||
The service validates the token and the share, marks the token as used
|
||||
and returns a signed JWT session token in a cookie plus the share's
|
||||
`permissionId` in the response body. Tokens are single-use.
|
||||
3. **Cleanup** — on the consumed `ShareRemoved` or `ShareExpired` event, the
|
||||
stored record is deleted.
|
||||
|
||||
## Configuration
|
||||
|
||||
The service is configured via `AUTH_GUEST_*` environment variables or a
|
||||
`auth-guest.yaml` file.
|
||||
|
||||
To run only the HTTP part, set `AUTH_GUEST_EVENTS_DISABLED=true`. To run only
|
||||
the event consumer, set `AUTH_GUEST_HTTP_DISABLED=true`.
|
||||
|
||||
Relevant options:
|
||||
|
||||
- `AUTH_GUEST_JWT_SECRET` — secret used to sign session tokens.
|
||||
- `AUTH_GUEST_JWT_COOKIE_NAME`, `AUTH_GUEST_JWT_TTL` — session cookie name and
|
||||
lifetime.
|
||||
- `AUTH_GUEST_TOKENS_STORAGE_ROOT` — where guest link token records are stored.
|
||||
- `AUTH_GUEST_SERVICE_ACCOUNT_ID`, `AUTH_GUEST_SERVICE_ACCOUNT_SECRET` — service
|
||||
account used to query the gateway for share metadata.
|
||||
- `AUTH_GUEST_NUM_CONSUMERS` — number of concurrent event consumers.
|
||||
- `OC_REVA_GATEWAY` — CS3 gateway used to look up shares.
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"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",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// not implemented
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/clihelper"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/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 auth-guest command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "auth-guest",
|
||||
Short: "starts auth-guest service",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
|
||||
"github.com/opencloud-eu/opencloud/pkg/generators"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/registry"
|
||||
"github.com/opencloud-eu/opencloud/pkg/runner"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/parser"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/metrics"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/server/debug"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/server/http"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
svcEvents "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/events"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"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"
|
||||
)
|
||||
|
||||
var _registeredEvents = []events.Unmarshaller{
|
||||
events.ShareCreated{},
|
||||
events.ShareRemoved{},
|
||||
events.ShareExpired{},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
tracerProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
cfg.RevaGateway,
|
||||
pool.WithTLSCACert(cfg.GRPCClientTLS.CACert),
|
||||
pool.WithTLSMode(tm),
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(tracerProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get reva client selector: %s", err)
|
||||
}
|
||||
|
||||
gr := runner.NewGroup()
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
mtrcs := metrics.New()
|
||||
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
tokenSvc := token.NewTokenService()
|
||||
store := storage.NewFileManager(cfg.Storage.RootDirectory)
|
||||
jwtService := jwt.NewJwtService(cfg.TokenManager.JWTSecret, cfg.JWT.TTL)
|
||||
|
||||
authGuest := authguest.NewAuthGuestService(tokenSvc, store,
|
||||
authguest.GatewaySelector(gatewaySelector),
|
||||
authguest.ServiceAccount(cfg.ServiceAccount),
|
||||
authguest.JWT(jwtService),
|
||||
)
|
||||
|
||||
if !cfg.HTTP.Disabled {
|
||||
server, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Config(cfg),
|
||||
http.Service(authGuest),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().
|
||||
Err(err).
|
||||
Str("transport", "http").
|
||||
Msg("Failed to initialize server")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
|
||||
} else {
|
||||
logger.Info().Msg("HTTP server disabled, not starting HTTP service")
|
||||
}
|
||||
|
||||
if !cfg.Events.Disabled {
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
evStream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig{
|
||||
Endpoint: cfg.Events.Endpoint,
|
||||
Cluster: cfg.Events.Cluster,
|
||||
EnableTLS: cfg.Events.EnableTLS,
|
||||
TLSInsecure: cfg.Events.TLSInsecure,
|
||||
TLSRootCACertificate: cfg.Events.TLSRootCACertificate,
|
||||
AuthUsername: cfg.Events.AuthUsername,
|
||||
AuthPassword: cfg.Events.AuthPassword,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize event stream")
|
||||
return err
|
||||
}
|
||||
|
||||
consumer, err := svcEvents.NewEventConsumer(
|
||||
evStream,
|
||||
svcEvents.Logger(logger),
|
||||
svcEvents.Context(ctx),
|
||||
svcEvents.RegisteredEvents(_registeredEvents),
|
||||
svcEvents.NumConsumers(cfg.NumConsumers),
|
||||
svcEvents.AuthGuestService(authGuest),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "event").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
|
||||
return consumer.Run()
|
||||
}, func() {
|
||||
consumer.Close()
|
||||
}))
|
||||
} else {
|
||||
logger.Info().Msg("event listening disabled, not starting event service")
|
||||
}
|
||||
|
||||
{
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("server", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"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 {
|
||||
// not implemented
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;AUTH_GUEST_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"%%NEXT%%"`
|
||||
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
Events Events `yaml:"events"`
|
||||
|
||||
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"%%NEXT%%"`
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
JWT JWT `yaml:"jwt"`
|
||||
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
NumConsumers int `yaml:"num_consumers" env:"AUTH_GUEST_NUM_CONSUMERS" desc:"The amount of concurrent event consumers to start. Event consumers are used for processing events. Multiple consumers increase parallelisation, but will also increase CPU and memory demands." introductionVersion:"%%NEXT%%"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Disabled bool `yaml:"disabled" env:"AUTH_GUEST_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle HTTP requests." introductionVersion:"%%NEXT%%"`
|
||||
Endpoint string `yaml:"endpoint" env:"OC_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." introductionVersion:"%%NEXT%%"`
|
||||
Cluster string `yaml:"cluster" env:"OC_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. Mandatory when using NATS as event system." introductionVersion:"%%NEXT%%"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided AUTH_GUEST_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"`
|
||||
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"`
|
||||
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// ServiceAccount is the configuration for the used service account
|
||||
type ServiceAccount struct {
|
||||
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;AUTH_GUEST_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"%%NEXT%%"`
|
||||
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;AUTH_GUEST_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// CORS defines the available cors configuration.
|
||||
type CORS struct {
|
||||
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;AUTH_GUEST_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
|
||||
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;AUTH_GUEST_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
|
||||
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;AUTH_GUEST_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
|
||||
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;AUTH_GUEST_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Disabled bool `yaml:"disabled" env:"AUTH_GUEST_HTTP_DISABLED" desc:"Disables the HTTP service. Set this to true if the service should only handle events." introductionVersion:"%%NEXT%%"`
|
||||
Addr string `yaml:"addr" env:"AUTH_GUEST_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"%%NEXT%%"`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"AUTH_GUEST_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"%%NEXT%%"`
|
||||
CORS CORS `yaml:"cors"`
|
||||
TLS shared.HTTPServiceTLS `yaml:"tls"`
|
||||
}
|
||||
|
||||
// Storage defines the configuration for the token storage.
|
||||
type Storage struct {
|
||||
RootDirectory string `yaml:"root_directory" env:"AUTH_GUEST_TOKENS_STORAGE_ROOT" desc:"The directory where the guest share tokens are stored. If not defined, the root directory derives from $OC_BASE_DATA_PATH/auth-guest." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"AUTH_GUEST_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// JWT defines the configuration for guest session tokens.
|
||||
type JWT struct {
|
||||
CookieName string `yaml:"cookie_name" env:"AUTH_GUEST_JWT_COOKIE_NAME" desc:"The name of the session cookie set when a guest token is redeemed." introductionVersion:"%%NEXT%%"`
|
||||
TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_JWT_TTL" desc:"The lifetime of a redeemed guest session token." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"AUTH_GUEST_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"%%NEXT%%"`
|
||||
Token string `yaml:"token" env:"AUTH_GUEST_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"%%NEXT%%"`
|
||||
Pprof bool `yaml:"pprof" env:"AUTH_GUEST_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"%%NEXT%%"`
|
||||
Zpages bool `yaml:"zpages" env:"AUTH_GUEST_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/defaults"
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns the full default config
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig return the default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9267",
|
||||
Token: "",
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "auth-guest",
|
||||
},
|
||||
NumConsumers: 1,
|
||||
Events: config.Events{
|
||||
Endpoint: "127.0.0.1:9233",
|
||||
Cluster: "opencloud-cluster",
|
||||
EnableTLS: false,
|
||||
},
|
||||
RevaGateway: shared.DefaultRevaConfig().Address,
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9266",
|
||||
Root: "/graph",
|
||||
Namespace: "eu.opencloud.web",
|
||||
CORS: config.CORS{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
|
||||
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Ocs-Apirequest"},
|
||||
AllowCredentials: true,
|
||||
},
|
||||
},
|
||||
Storage: config.Storage{
|
||||
RootDirectory: path.Join(defaults.BaseDataPath(), "auth-guest"),
|
||||
},
|
||||
JWT: config.JWT{
|
||||
CookieName: "__Host-oc_guest_session",
|
||||
TTL: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults ensures the config contains default values
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
|
||||
if cfg.Commons != nil {
|
||||
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitizes the config
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/opencloud-eu/opencloud/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config/defaults"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/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)
|
||||
}
|
||||
|
||||
// Validate validates the config
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager == nil || cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "opencloud"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "auth-guest"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build information",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
|
||||
_ = prometheus.Register(
|
||||
m.BuildInfo,
|
||||
)
|
||||
|
||||
// TODO: implement metrics
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/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,43 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/checks"
|
||||
"github.com/opencloud-eu/opencloud/pkg/handlers"
|
||||
"github.com/opencloud-eu/opencloud/pkg/nats"
|
||||
"github.com/opencloud-eu/opencloud/pkg/service/debug"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("http reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
|
||||
|
||||
secureOption := nats.Secure(
|
||||
options.Config.Events.EnableTLS,
|
||||
options.Config.Events.TLSInsecure,
|
||||
options.Config.Events.TLSRootCACertificate,
|
||||
)
|
||||
readyHandlerConfiguration := healthHandlerConfiguration.
|
||||
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint, secureOption))
|
||||
|
||||
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)),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
ErrorType string `json:"errorType"`
|
||||
Message string `json:"message"`
|
||||
PermissionID string `json:"permissionId"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, body errorResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeRedeemError(w http.ResponseWriter, err error) {
|
||||
var re *authguest.RedeemError
|
||||
if !errors.As(err, &re) {
|
||||
writeError(w, http.StatusInternalServerError, errorResponse{ErrorType: "internalError", Message: "An internal error occurred."})
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusInternalServerError
|
||||
errorType := "internalError"
|
||||
switch {
|
||||
case errors.Is(re.ErrorType, authguest.ErrExpired):
|
||||
status, errorType = http.StatusUnauthorized, "tokenExpired"
|
||||
case errors.Is(re.ErrorType, token.ErrInvalidToken):
|
||||
status, errorType = http.StatusUnauthorized, "tokenInvalid"
|
||||
case errors.Is(re.ErrorType, storage.ErrNotFound):
|
||||
status, errorType = http.StatusNotFound, "tokenNotFound"
|
||||
case errors.Is(re.ErrorType, storage.ErrInvalidHash):
|
||||
status, errorType = http.StatusUnauthorized, "tokenInvalid"
|
||||
case errors.Is(re.ErrorType, authguest.ErrAlreadyRedeemed):
|
||||
status, errorType = http.StatusConflict, "tokenAlreadyRedeemed"
|
||||
case errors.Is(re.ErrorType, authguest.ErrShareNotFound):
|
||||
status, errorType = http.StatusNotFound, "shareNotFound"
|
||||
case errors.Is(re.ErrorType, authguest.ErrShareExpired):
|
||||
status, errorType = http.StatusGone, "shareExpired"
|
||||
}
|
||||
|
||||
message := re.ErrorType.Error()
|
||||
if errorType == "internalError" {
|
||||
message = "An internal error occurred."
|
||||
}
|
||||
|
||||
writeError(w, status, errorResponse{ErrorType: errorType, Message: message, PermissionID: re.ShareID})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"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
|
||||
Service authguest.AuthGuest
|
||||
Flags []pflag.Flag
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Service provides a function to set the service option.
|
||||
func Service(val authguest.AuthGuest) Option {
|
||||
return func(o *Options) {
|
||||
o.Service = 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...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
)
|
||||
|
||||
// RedeemRequest is the request body for token redemption.
|
||||
type RedeemRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type redeemResponse struct {
|
||||
PermissionID string `json:"permissionId"`
|
||||
}
|
||||
|
||||
// RedeemHandler validates the token submitted to the redeem endpoint.
|
||||
func RedeemHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req RedeemRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Debug().Err(err).Msg("request body is malformed")
|
||||
writeError(w, http.StatusBadRequest, errorResponse{ErrorType: "invalidRequest", Message: "The request body is malformed."})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Redeem(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("redeem failed")
|
||||
writeRedeemError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cfg.JWT.CookieName,
|
||||
Value: result.SessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(cfg.JWT.TTL.Seconds()),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(redeemResponse{PermissionID: result.ShareID})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRedeemHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWT{
|
||||
CookieName: "__Host-oc_guest_session",
|
||||
TTL: time.Hour,
|
||||
},
|
||||
}
|
||||
return RedeemHandler(log.NopLogger(), svc, cfg)
|
||||
}
|
||||
|
||||
func TestRedeemHandler(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("Redeem", mock.Anything, "valid-token").Return(&authguest.RedeemResponse{SessionToken: "session-token", ShareID: "share-1"}, nil)
|
||||
|
||||
body, err := json.Marshal(RedeemRequest{Token: "valid-token"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))))
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var cookie *http.Cookie
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.Name == "__Host-oc_guest_session" {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie)
|
||||
assert.Equal(t, "session-token", cookie.Value)
|
||||
assert.True(t, cookie.HttpOnly)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, "/", cookie.Path)
|
||||
|
||||
var resp redeemResponse
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
|
||||
assert.Equal(t, "share-1", resp.PermissionID)
|
||||
}
|
||||
|
||||
func TestRedeemHandlerErrorMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantType string
|
||||
wantPermission string
|
||||
}{
|
||||
{
|
||||
name: "token expired",
|
||||
err: &authguest.RedeemError{ErrorType: authguest.ErrExpired, ShareID: "share-1"},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantType: "tokenExpired",
|
||||
wantPermission: "share-1",
|
||||
},
|
||||
{
|
||||
name: "token invalid",
|
||||
err: &authguest.RedeemError{ErrorType: token.ErrInvalidToken},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantType: "tokenInvalid",
|
||||
},
|
||||
{
|
||||
name: "token not found",
|
||||
err: &authguest.RedeemError{ErrorType: storage.ErrNotFound},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantType: "tokenNotFound",
|
||||
},
|
||||
{
|
||||
name: "token already redeemed",
|
||||
err: &authguest.RedeemError{ErrorType: authguest.ErrAlreadyRedeemed},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantType: "tokenAlreadyRedeemed",
|
||||
},
|
||||
{
|
||||
name: "share not found",
|
||||
err: &authguest.RedeemError{ErrorType: authguest.ErrShareNotFound},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantType: "shareNotFound",
|
||||
},
|
||||
{
|
||||
name: "share expired",
|
||||
err: &authguest.RedeemError{ErrorType: authguest.ErrShareExpired},
|
||||
wantStatus: http.StatusGone,
|
||||
wantType: "shareExpired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("Redeem", mock.Anything, "token").Return(nil, tt.err)
|
||||
|
||||
body, err := json.Marshal(RedeemRequest{Token: "token"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))))
|
||||
|
||||
assert.Equal(t, tt.wantStatus, rr.Code)
|
||||
|
||||
var resp errorResponse
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
|
||||
assert.Equal(t, tt.wantType, resp.ErrorType)
|
||||
assert.Equal(t, tt.wantPermission, resp.PermissionID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemHandlerMalformedBody(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
newRedeemHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not-json")))
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
|
||||
var resp errorResponse
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
|
||||
assert.Equal(t, "invalidRequest", resp.ErrorType)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/opencloud-eu/opencloud/pkg/cors"
|
||||
"github.com/opencloud-eu/opencloud/pkg/middleware"
|
||||
ohttp "github.com/opencloud-eu/opencloud/pkg/service/http"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (ohttp.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
newService, err := ohttp.NewService(
|
||||
ohttp.TLSConfig(options.Config.HTTP.TLS),
|
||||
ohttp.Logger(options.Logger),
|
||||
ohttp.Namespace(options.Config.HTTP.Namespace),
|
||||
ohttp.Name(options.Config.Service.Name),
|
||||
ohttp.Version(version.GetString()),
|
||||
ohttp.Address(options.Config.HTTP.Addr),
|
||||
ohttp.Context(options.Context),
|
||||
ohttp.Flags(options.Flags...),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing http service")
|
||||
return ohttp.Service{}, err
|
||||
}
|
||||
|
||||
middlewares := []func(http.Handler) http.Handler{
|
||||
chimiddleware.RequestID,
|
||||
middleware.Version(
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
middleware.Logger(
|
||||
options.Logger,
|
||||
),
|
||||
middleware.TraceContext,
|
||||
middleware.Cors(
|
||||
cors.Logger(options.Logger),
|
||||
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
|
||||
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
|
||||
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
|
||||
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
|
||||
),
|
||||
}
|
||||
|
||||
mux := chi.NewMux()
|
||||
mux.Use(middlewares...)
|
||||
|
||||
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/redeem", RedeemHandler(options.Logger, options.Service, options.Config))
|
||||
})
|
||||
|
||||
err = micro.RegisterHandler(newService.Server(), mux)
|
||||
if err != nil {
|
||||
options.Logger.Fatal().Err(err).Msg("failed to register the handler")
|
||||
}
|
||||
|
||||
newService.Init()
|
||||
return newService, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewAuthGuest creates a new instance of AuthGuest. 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 NewAuthGuest(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *AuthGuest {
|
||||
mock := &AuthGuest{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// AuthGuest is an autogenerated mock type for the AuthGuest type
|
||||
type AuthGuest struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type AuthGuest_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *AuthGuest) EXPECT() *AuthGuest_Expecter {
|
||||
return &AuthGuest_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// CleanupShare provides a mock function for the type AuthGuest
|
||||
func (_mock *AuthGuest) CleanupShare(shareID string) error {
|
||||
ret := _mock.Called(shareID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CleanupShare")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(shareID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// AuthGuest_CleanupShare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupShare'
|
||||
type AuthGuest_CleanupShare_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CleanupShare is a helper method to define mock.On call
|
||||
// - shareID string
|
||||
func (_e *AuthGuest_Expecter) CleanupShare(shareID any) *AuthGuest_CleanupShare_Call {
|
||||
return &AuthGuest_CleanupShare_Call{Call: _e.mock.On("CleanupShare", shareID)}
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CleanupShare_Call) Run(run func(shareID string)) *AuthGuest_CleanupShare_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CleanupShare_Call) Return(err error) *AuthGuest_CleanupShare_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CleanupShare_Call) RunAndReturn(run func(shareID string) error) *AuthGuest_CleanupShare_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateToken provides a mock function for the type AuthGuest
|
||||
func (_mock *AuthGuest) CreateToken(ctx context.Context, shareID string) (*token.Token, error) {
|
||||
ret := _mock.Called(ctx, shareID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateToken")
|
||||
}
|
||||
|
||||
var r0 *token.Token
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*token.Token, error)); ok {
|
||||
return returnFunc(ctx, shareID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) *token.Token); ok {
|
||||
r0 = returnFunc(ctx, shareID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*token.Token)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = returnFunc(ctx, shareID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// AuthGuest_CreateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateToken'
|
||||
type AuthGuest_CreateToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateToken is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - shareID string
|
||||
func (_e *AuthGuest_Expecter) CreateToken(ctx any, shareID any) *AuthGuest_CreateToken_Call {
|
||||
return &AuthGuest_CreateToken_Call{Call: _e.mock.On("CreateToken", ctx, shareID)}
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CreateToken_Call) Run(run func(ctx context.Context, shareID string)) *AuthGuest_CreateToken_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)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CreateToken_Call) Return(token1 *token.Token, err error) *AuthGuest_CreateToken_Call {
|
||||
_c.Call.Return(token1, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_CreateToken_Call) RunAndReturn(run func(ctx context.Context, shareID string) (*token.Token, error)) *AuthGuest_CreateToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Redeem provides a mock function for the type AuthGuest
|
||||
func (_mock *AuthGuest) Redeem(ctx context.Context, tokenString string) (*authguest.RedeemResponse, error) {
|
||||
ret := _mock.Called(ctx, tokenString)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Redeem")
|
||||
}
|
||||
|
||||
var r0 *authguest.RedeemResponse
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*authguest.RedeemResponse, error)); ok {
|
||||
return returnFunc(ctx, tokenString)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) *authguest.RedeemResponse); ok {
|
||||
r0 = returnFunc(ctx, tokenString)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*authguest.RedeemResponse)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = returnFunc(ctx, tokenString)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// AuthGuest_Redeem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Redeem'
|
||||
type AuthGuest_Redeem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Redeem is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - tokenString string
|
||||
func (_e *AuthGuest_Expecter) Redeem(ctx any, tokenString any) *AuthGuest_Redeem_Call {
|
||||
return &AuthGuest_Redeem_Call{Call: _e.mock.On("Redeem", ctx, tokenString)}
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_Redeem_Call) Run(run func(ctx context.Context, tokenString string)) *AuthGuest_Redeem_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)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_Redeem_Call) Return(redeemResponse *authguest.RedeemResponse, err error) *AuthGuest_Redeem_Call {
|
||||
_c.Call.Return(redeemResponse, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_Redeem_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (*authguest.RedeemResponse, error)) *AuthGuest_Redeem_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package authguest
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
type Option func(*Options)
|
||||
|
||||
// Options for the auth-guest service
|
||||
type Options struct {
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
ServiceAccount config.ServiceAccount
|
||||
JWT *jwt.JwtService
|
||||
}
|
||||
|
||||
// GatewaySelector adds a grpc client selector for the gateway service
|
||||
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = gatewaySelector
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceAccount configures a service account for the auth-guest service
|
||||
func ServiceAccount(sa config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.ServiceAccount = sa
|
||||
}
|
||||
}
|
||||
|
||||
// JWT configures the jwt service for the auth-guest service
|
||||
func JWT(m *jwt.JwtService) Option {
|
||||
return func(o *Options) {
|
||||
o.JWT = m
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package authguest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrExpired = errors.New("token expired")
|
||||
var ErrAlreadyRedeemed = errors.New("token already redeemed")
|
||||
var ErrShareNotFound = errors.New("share not found")
|
||||
var ErrShareExpired = errors.New("share expired")
|
||||
|
||||
const guestLinkTokenTTL = 30 * time.Minute
|
||||
|
||||
// RedeemError wraps a redeem failure together with the share id. The HTTP
|
||||
// transport inspects ErrorType to choose a status code and message.
|
||||
type RedeemError struct {
|
||||
ErrorType error
|
||||
ShareID string
|
||||
}
|
||||
|
||||
func (e *RedeemError) Error() string { return e.ErrorType.Error() }
|
||||
|
||||
// RedeemResponse is the result of a successful token redemption.
|
||||
type RedeemResponse struct {
|
||||
SessionToken string
|
||||
ShareID string
|
||||
}
|
||||
|
||||
// AuthGuest is the domain service used by the transport and event layers.
|
||||
type AuthGuest interface {
|
||||
CreateToken(ctx context.Context, shareID string) (*token.Token, error)
|
||||
Redeem(ctx context.Context, tokenString string) (*RedeemResponse, error)
|
||||
CleanupShare(shareID string) error
|
||||
}
|
||||
|
||||
var _ AuthGuest = (*AuthGuestService)(nil)
|
||||
|
||||
// AuthGuestService contains the business logic shared by auth-guest transport services.
|
||||
type AuthGuestService struct {
|
||||
tokenSvc *token.TokenService
|
||||
store storage.Manager
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
serviceAccount config.ServiceAccount
|
||||
jwtService *jwt.JwtService
|
||||
}
|
||||
|
||||
func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, opts ...Option) *AuthGuestService {
|
||||
o := &Options{}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
return &AuthGuestService{
|
||||
tokenSvc: tokenSvc,
|
||||
store: store,
|
||||
gatewaySelector: o.GatewaySelector,
|
||||
serviceAccount: o.ServiceAccount,
|
||||
jwtService: o.JWT,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthGuestService) CreateToken(ctx context.Context, shareID string) (*token.Token, error) {
|
||||
tok, err := s.tokenSvc.Generate(shareID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.store.Add(storage.Record{
|
||||
ShareID: shareID,
|
||||
ShareIDHash: tok.ShareIDHash,
|
||||
SecretHash: tok.SecretHash(),
|
||||
Expiry: time.Now().Add(guestLinkTokenTTL),
|
||||
Redeemed: false,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Redeem validates a token and its share and exchanges them for a session token
|
||||
// and the share id.
|
||||
func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*RedeemResponse, error) {
|
||||
rec, err := s.verifyToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.validateShare(ctx, rec.ShareID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.store.Redeem(rec.ShareIDHash); err != nil {
|
||||
if errors.Is(err, storage.ErrAlreadyRedeemed) {
|
||||
return nil, &RedeemError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionToken, err := s.jwtService.Sign(rec.ShareID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RedeemResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil
|
||||
}
|
||||
|
||||
// CleanupShare removes a share's token record from storage. Missing records are ignored.
|
||||
func (s *AuthGuestService) CleanupShare(shareID string) error {
|
||||
shareIDHash := token.Hash(shareID)
|
||||
err := s.store.Remove(shareIDHash)
|
||||
if err != nil && err != storage.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyToken validates a token and returns its stored record.
|
||||
func (s *AuthGuestService) verifyToken(tokenString string) (*storage.Record, error) {
|
||||
tok, err := s.tokenSvc.Parse(tokenString)
|
||||
if err != nil {
|
||||
return nil, &RedeemError{ErrorType: err}
|
||||
}
|
||||
|
||||
rec, err := s.store.Get(tok.ShareIDHash)
|
||||
if err != nil {
|
||||
return nil, &RedeemError{ErrorType: err}
|
||||
}
|
||||
|
||||
if err := s.tokenSvc.Verify(*tok, rec.SecretHash); err != nil {
|
||||
return nil, &RedeemError{ErrorType: err, ShareID: rec.ShareID}
|
||||
}
|
||||
|
||||
if !rec.Expiry.IsZero() && rec.Expiry.Before(time.Now()) {
|
||||
return nil, &RedeemError{ErrorType: ErrExpired, ShareID: rec.ShareID}
|
||||
}
|
||||
|
||||
if rec.Redeemed {
|
||||
return nil, &RedeemError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID}
|
||||
}
|
||||
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// validateShare extracts the share information from the gateway and checks its existence and expiration.
|
||||
func (s *AuthGuestService) validateShare(ctx context.Context, shareID string) (*collaboration.Share, error) {
|
||||
share, err := s.getShare(ctx, shareID)
|
||||
if err != nil {
|
||||
return nil, &RedeemError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
|
||||
if exp := utils.TSToTime(share.GetExpiration()); !exp.IsZero() && exp.Before(time.Now()) {
|
||||
return nil, &RedeemError{ErrorType: ErrShareExpired, ShareID: shareID}
|
||||
}
|
||||
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// getShare fetches a share from the gateway.
|
||||
func (s *AuthGuestService) getShare(ctx context.Context, shareID string) (*collaboration.Share, error) {
|
||||
gwc, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, err = utils.GetServiceUserContextWithContext(ctx, gwc, s.serviceAccount.ServiceAccountID, s.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := gwc.GetShare(ctx, &collaboration.GetShareRequest{
|
||||
Ref: &collaboration.ShareReference{
|
||||
Spec: &collaboration.ShareReference_Id{
|
||||
Id: &collaboration.ShareId{
|
||||
OpaqueId: shareID,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch resp.GetStatus().GetCode() {
|
||||
case rpc.Code_CODE_OK:
|
||||
case rpc.Code_CODE_NOT_FOUND:
|
||||
return nil, ErrShareNotFound
|
||||
default:
|
||||
return nil, fmt.Errorf("could not get share %s: %s", shareID, resp.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
share := resp.GetShare()
|
||||
if share == nil {
|
||||
return nil, ErrShareNotFound
|
||||
}
|
||||
|
||||
return share, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package authguest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
storagemocks "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage/mocks"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testShareID = "e0123456-7890-abcd-ef01-234567890abc"
|
||||
|
||||
type gatewayTestSelector struct {
|
||||
client gateway.GatewayAPIClient
|
||||
}
|
||||
|
||||
func (s gatewayTestSelector) Next(...pool.Option) (gateway.GatewayAPIClient, error) {
|
||||
return s.client, nil
|
||||
}
|
||||
|
||||
func newGatewayTestSelector(client gateway.GatewayAPIClient) pool.Selectable[gateway.GatewayAPIClient] {
|
||||
return gatewayTestSelector{client: client}
|
||||
}
|
||||
|
||||
func newGatewayMock(resp *collaboration.GetShareResponse) *cs3mocks.GatewayAPIClient {
|
||||
gwc := &cs3mocks.GatewayAPIClient{}
|
||||
gwc.On("Authenticate", mock.Anything, mock.Anything).
|
||||
Return(&gateway.AuthenticateResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Token: "token",
|
||||
}, nil)
|
||||
gwc.On("GetShare", mock.Anything, mock.Anything).Return(resp, nil)
|
||||
return gwc
|
||||
}
|
||||
|
||||
func newToken(t *testing.T) (string, storage.Record) {
|
||||
ts := token.NewTokenService()
|
||||
tok, err := ts.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := storage.Record{
|
||||
ShareID: testShareID,
|
||||
ShareIDHash: tok.ShareIDHash,
|
||||
SecretHash: tok.SecretHash(),
|
||||
Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
|
||||
}
|
||||
|
||||
return tok.String(), rec
|
||||
}
|
||||
|
||||
func newShareService(t *testing.T, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService {
|
||||
t.Helper()
|
||||
return NewAuthGuestService(
|
||||
token.NewTokenService(),
|
||||
storagemocks.NewManager(t),
|
||||
GatewaySelector(newGatewayTestSelector(gwc)),
|
||||
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
|
||||
)
|
||||
}
|
||||
|
||||
func newRedeemService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService {
|
||||
t.Helper()
|
||||
return NewAuthGuestService(
|
||||
token.NewTokenService(),
|
||||
store,
|
||||
GatewaySelector(newGatewayTestSelector(gwc)),
|
||||
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
|
||||
JWT(jwt.NewJwtService("test-secret", time.Hour)),
|
||||
)
|
||||
}
|
||||
|
||||
func TestCreateTokenPersistsRecord(t *testing.T) {
|
||||
store := storagemocks.NewManager(t)
|
||||
expiry := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
gwc := newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: &collaboration.Share{
|
||||
Id: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
Expiration: utils.TimeToTS(expiry),
|
||||
},
|
||||
})
|
||||
|
||||
var added storage.Record
|
||||
store.On("Add", mock.Anything).Run(func(args mock.Arguments) {
|
||||
added = args.Get(0).(storage.Record)
|
||||
}).Return(nil)
|
||||
|
||||
s := NewAuthGuestService(
|
||||
token.NewTokenService(),
|
||||
store,
|
||||
GatewaySelector(newGatewayTestSelector(gwc)),
|
||||
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
|
||||
)
|
||||
|
||||
tok, err := s.CreateToken(context.Background(), testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
store.AssertCalled(t, "Add", mock.Anything)
|
||||
assert.Equal(t, testShareID, added.ShareID)
|
||||
assert.Equal(t, tok.ShareIDHash, added.ShareIDHash)
|
||||
assert.Equal(t, tok.SecretHash(), added.SecretHash)
|
||||
assert.WithinDuration(t, time.Now().Add(guestLinkTokenTTL), added.Expiry, time.Minute)
|
||||
assert.False(t, added.Redeemed)
|
||||
}
|
||||
|
||||
func TestVerifyToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expired bool
|
||||
redeemed bool
|
||||
wantErr error
|
||||
}{
|
||||
{name: "valid"},
|
||||
{name: "expired", expired: true, wantErr: ErrExpired},
|
||||
{name: "already redeemed", redeemed: true, wantErr: ErrAlreadyRedeemed},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := storagemocks.NewManager(t)
|
||||
s := NewAuthGuestService(token.NewTokenService(), store)
|
||||
tok, rec := newToken(t)
|
||||
if tt.expired {
|
||||
rec.Expiry = time.Now().Add(-time.Hour)
|
||||
}
|
||||
if tt.redeemed {
|
||||
rec.Redeemed = true
|
||||
}
|
||||
store.On("Get", rec.ShareIDHash).Return(rec, nil)
|
||||
|
||||
got, err := s.verifyToken(tok)
|
||||
if tt.wantErr != nil {
|
||||
var re *RedeemError
|
||||
require.ErrorAs(t, err, &re)
|
||||
assert.ErrorIs(t, re.ErrorType, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, *got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateShare(t *testing.T) {
|
||||
share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}}
|
||||
notExpiredShare := &collaboration.Share{
|
||||
Id: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
Expiration: utils.TimeToTS(time.Now().Add(time.Hour)),
|
||||
}
|
||||
expiredShare := &collaboration.Share{
|
||||
Id: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
Expiration: utils.TimeToTS(time.Now().Add(-time.Hour)),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response *collaboration.GetShareResponse
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: share},
|
||||
},
|
||||
{
|
||||
name: "not expired",
|
||||
response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: notExpiredShare},
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Share: expiredShare},
|
||||
wantErr: ErrShareExpired,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND}},
|
||||
wantErr: ErrShareNotFound,
|
||||
},
|
||||
{
|
||||
name: "nil share",
|
||||
response: &collaboration.GetShareResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}},
|
||||
wantErr: ErrShareNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newShareService(t, newGatewayMock(tt.response))
|
||||
|
||||
got, err := s.validateShare(context.Background(), testShareID)
|
||||
if tt.wantErr != nil {
|
||||
var re *RedeemError
|
||||
require.ErrorAs(t, err, &re)
|
||||
assert.ErrorIs(t, re.ErrorType, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.response.GetShare(), got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeem(t *testing.T) {
|
||||
store := storagemocks.NewManager(t)
|
||||
tok, rec := newToken(t)
|
||||
store.On("Get", rec.ShareIDHash).Return(rec, nil)
|
||||
store.On("Redeem", rec.ShareIDHash).Return(nil)
|
||||
|
||||
share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}}
|
||||
s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: share,
|
||||
}))
|
||||
|
||||
result, err := s.Redeem(context.Background(), tok)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.SessionToken)
|
||||
assert.Equal(t, testShareID, result.ShareID)
|
||||
|
||||
store.AssertCalled(t, "Redeem", rec.ShareIDHash)
|
||||
}
|
||||
|
||||
func TestRedeemAlreadyRedeemed(t *testing.T) {
|
||||
store := storagemocks.NewManager(t)
|
||||
tok, rec := newToken(t)
|
||||
store.On("Get", rec.ShareIDHash).Return(rec, nil)
|
||||
store.On("Redeem", rec.ShareIDHash).Return(storage.ErrAlreadyRedeemed)
|
||||
|
||||
share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}}
|
||||
s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: share,
|
||||
}))
|
||||
|
||||
_, err := s.Redeem(context.Background(), tok)
|
||||
var re *RedeemError
|
||||
require.ErrorAs(t, err, &re)
|
||||
assert.ErrorIs(t, re.ErrorType, ErrAlreadyRedeemed)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
ocEvents "github.com/opencloud-eu/opencloud/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
)
|
||||
|
||||
// handleShareCreated handles a share created event.
|
||||
func (s *EventConsumer) handleShareCreated(ctx context.Context, ev events.ShareCreated) error {
|
||||
_, span := tracer.Start(ctx, "handleShareCreated")
|
||||
defer span.End()
|
||||
|
||||
if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST {
|
||||
s.log.Debug().Msg("share created event is not for a guest, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
tok, err := s.authGuest.CreateToken(ctx, ev.ShareID.GetOpaqueId())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return events.Publish(ctx, s.stream, ocEvents.GuestTokenCreated{
|
||||
ShareID: ev.ShareID,
|
||||
Sharer: ev.Sharer,
|
||||
GranteeEmail: ev.GranteeUserID.GetOpaqueId(),
|
||||
ItemID: ev.ItemID,
|
||||
ResourceName: ev.ResourceName,
|
||||
Token: tok.String(),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleShareRemoved handles a share removed event.
|
||||
func (s *EventConsumer) handleShareRemoved(ctx context.Context, ev events.ShareRemoved) error {
|
||||
_, span := tracer.Start(ctx, "handleShareRemoved")
|
||||
defer span.End()
|
||||
|
||||
if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST {
|
||||
s.log.Debug().Msg("share removed event is not for a guest, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log.Debug().Interface("event", ev).Msg("share removed event received")
|
||||
|
||||
return s.authGuest.CleanupShare(ev.ShareID.GetOpaqueId())
|
||||
}
|
||||
|
||||
// handleShareExpired handles a share expired event.
|
||||
func (s *EventConsumer) handleShareExpired(ctx context.Context, ev events.ShareExpired) error {
|
||||
_, span := tracer.Start(ctx, "handleShareExpired")
|
||||
defer span.End()
|
||||
|
||||
if ev.GranteeUserID == nil || ev.GranteeUserID.GetType() != user.UserType_USER_TYPE_GUEST {
|
||||
s.log.Debug().Msg("share expired event is not for a guest, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log.Debug().Interface("event", ev).Msg("share expired event received")
|
||||
|
||||
return s.authGuest.CleanupShare(ev.ShareID.GetOpaqueId())
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
ocEvents "github.com/opencloud-eu/opencloud/pkg/events"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
microevents "go-micro.dev/v4/events"
|
||||
)
|
||||
|
||||
const testShareID = "e0123456-7890-abcd-ef01-234567890abc"
|
||||
|
||||
type testBus struct {
|
||||
published []any
|
||||
}
|
||||
|
||||
func (tb *testBus) Publish(_ string, ev any, _ ...microevents.PublishOption) error {
|
||||
tb.published = append(tb.published, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *testBus) Consume(_ string, _ ...microevents.ConsumeOption) (<-chan microevents.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newConsumer(t *testing.T, authGuest authguest.AuthGuest) (*EventConsumer, *testBus) {
|
||||
t.Helper()
|
||||
bus := &testBus{}
|
||||
consumer, err := NewEventConsumer(bus, AuthGuestService(authGuest))
|
||||
require.NoError(t, err)
|
||||
|
||||
return consumer, bus
|
||||
}
|
||||
|
||||
func TestHandleShareCreated(t *testing.T) {
|
||||
tok, err := token.NewTokenService().Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("CreateToken", mock.Anything, testShareID).Return(tok, nil)
|
||||
|
||||
svc, bus := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareCreated{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
Sharer: &user.UserId{OpaqueId: "sharer"},
|
||||
ItemID: &provider.ResourceId{StorageId: "storage", OpaqueId: "item"},
|
||||
ResourceName: "resource",
|
||||
GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareCreated(context.Background(), ev))
|
||||
|
||||
svcMock.AssertCalled(t, "CreateToken", mock.Anything, testShareID)
|
||||
|
||||
require.Len(t, bus.published, 1)
|
||||
published, ok := bus.published[0].(ocEvents.GuestTokenCreated)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, testShareID, published.ShareID.GetOpaqueId())
|
||||
assert.Equal(t, ev.Sharer, published.Sharer)
|
||||
assert.Equal(t, "guest@example.org", published.GranteeEmail)
|
||||
assert.Equal(t, ev.ItemID, published.ItemID)
|
||||
assert.Equal(t, ev.ResourceName, published.ResourceName)
|
||||
assert.Equal(t, tok.String(), published.Token)
|
||||
}
|
||||
|
||||
func TestHandleShareCreatedSkipsNonGuest(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svc, bus := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareCreated{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareCreated(context.Background(), ev))
|
||||
|
||||
svcMock.AssertNotCalled(t, "CreateToken", mock.Anything, mock.Anything)
|
||||
assert.Empty(t, bus.published)
|
||||
}
|
||||
|
||||
func TestHandleShareRemoved(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("CleanupShare", testShareID).Return(nil)
|
||||
svc, _ := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareRemoved{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareRemoved(context.Background(), ev))
|
||||
|
||||
svcMock.AssertCalled(t, "CleanupShare", testShareID)
|
||||
}
|
||||
|
||||
func TestHandleShareRemovedSkipsNonGuest(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svc, _ := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareRemoved{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareRemoved(context.Background(), ev))
|
||||
|
||||
svcMock.AssertNotCalled(t, "CleanupShare", mock.Anything)
|
||||
}
|
||||
|
||||
func TestHandleShareExpired(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("CleanupShare", testShareID).Return(nil)
|
||||
svc, _ := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareExpired{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
GranteeUserID: &user.UserId{OpaqueId: "guest@example.org", Type: user.UserType_USER_TYPE_GUEST},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareExpired(context.Background(), ev))
|
||||
|
||||
svcMock.AssertCalled(t, "CleanupShare", testShareID)
|
||||
}
|
||||
|
||||
func TestHandleShareExpiredSkipsNonGuest(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svc, _ := newConsumer(t, svcMock)
|
||||
|
||||
ev := events.ShareExpired{
|
||||
ShareID: &collaboration.ShareId{OpaqueId: testShareID},
|
||||
GranteeUserID: &user.UserId{OpaqueId: "user", Type: user.UserType_USER_TYPE_PRIMARY},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.handleShareExpired(context.Background(), ev))
|
||||
|
||||
svcMock.AssertNotCalled(t, "CleanupShare", mock.Anything)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
)
|
||||
|
||||
// Option for the auth-guest service
|
||||
type Option func(*Options)
|
||||
|
||||
// Options for the auth-guest service
|
||||
type Options struct {
|
||||
Context context.Context
|
||||
Logger log.Logger
|
||||
Stream events.Stream
|
||||
RegisteredEvents []events.Unmarshaller
|
||||
NumConsumers int
|
||||
AuthGuestService authguest.AuthGuest
|
||||
}
|
||||
|
||||
// Context configures a context for the auth-guest service
|
||||
func Context(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// Logger configures a logger for the auth-guest service
|
||||
func Logger(log log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = log
|
||||
}
|
||||
}
|
||||
|
||||
// Stream configures an event stream for the auth-guest service
|
||||
func Stream(s events.Stream) Option {
|
||||
return func(o *Options) {
|
||||
o.Stream = s
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredEvents registers the events the service should listen to
|
||||
func RegisteredEvents(e []events.Unmarshaller) Option {
|
||||
return func(o *Options) {
|
||||
o.RegisteredEvents = e
|
||||
}
|
||||
}
|
||||
|
||||
// NumConsumers configures the amount of concurrent event consumers
|
||||
func NumConsumers(num int) Option {
|
||||
return func(o *Options) {
|
||||
o.NumConsumers = num
|
||||
}
|
||||
}
|
||||
|
||||
// AuthGuestService configures the guest auth domain service.
|
||||
func AuthGuestService(s authguest.AuthGuest) Option {
|
||||
return func(o *Options) {
|
||||
o.AuthGuestService = s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer trace.Tracer
|
||||
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/events")
|
||||
}
|
||||
|
||||
var (
|
||||
_numConsumersDefault = 1
|
||||
)
|
||||
|
||||
// EventConsumer consumes guest share events.
|
||||
type EventConsumer struct {
|
||||
ctx context.Context
|
||||
log log.Logger
|
||||
stream events.Stream
|
||||
|
||||
authGuest authguest.AuthGuest
|
||||
|
||||
numConsumers int
|
||||
|
||||
events []events.Unmarshaller
|
||||
|
||||
stopCh chan struct{}
|
||||
stopped *atomic.Bool
|
||||
}
|
||||
|
||||
// NewEventConsumer creates a new event consumer.
|
||||
func NewEventConsumer(stream events.Stream, opts ...Option) (*EventConsumer, error) {
|
||||
o := &Options{
|
||||
NumConsumers: _numConsumersDefault,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
s := &EventConsumer{
|
||||
ctx: o.Context,
|
||||
log: o.Logger,
|
||||
stream: stream,
|
||||
authGuest: o.AuthGuestService,
|
||||
events: o.RegisteredEvents,
|
||||
numConsumers: o.NumConsumers,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
stopped: new(atomic.Bool),
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run to fulfil Runner interface
|
||||
func (s *EventConsumer) Run() error {
|
||||
ch, err := events.Consume(s.stream, "auth-guest", s.events...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
s.log.Debug().Int("worker.count", s.numConsumers).
|
||||
Str("messaging.consumer.group.name", "auth-guest").
|
||||
Str("messaging.system", "nats").
|
||||
Str("messaging.operation.name", "receive").
|
||||
Msg("starting event processing workers")
|
||||
|
||||
// start workers
|
||||
for i := range s.numConsumers {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.processEvent(e); err != nil {
|
||||
s.log.Error().Err(err).
|
||||
Int("worker", workerID).
|
||||
Interface("event", e).
|
||||
Msg("failed to process event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// wait for stop signal
|
||||
<-s.stopCh
|
||||
cancel() // signal workers to stop
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will make the service to stop processing, so the `Run`
|
||||
// method can finish.
|
||||
func (s *EventConsumer) Close() {
|
||||
if s.stopped.CompareAndSwap(false, true) {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
// processEvent dispatches an event to the matching handler.
|
||||
func (s *EventConsumer) processEvent(e events.Event) error {
|
||||
ctx := e.GetTraceContext(s.ctx)
|
||||
ctx, span := tracer.Start(ctx, "processEvent")
|
||||
defer span.End()
|
||||
|
||||
s.log.Debug().Interface("event", e).Msg("processing event")
|
||||
|
||||
switch ev := e.Event.(type) {
|
||||
case events.ShareCreated:
|
||||
return s.handleShareCreated(ctx, ev)
|
||||
case events.ShareRemoved:
|
||||
return s.handleShareRemoved(ctx, ev)
|
||||
case events.ShareExpired:
|
||||
return s.handleShareExpired(ctx, ev)
|
||||
default:
|
||||
s.log.Warn().
|
||||
Str("eventtype", e.Type).
|
||||
Msg("unhandled event")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type jwtClaims struct {
|
||||
ShareID string `json:"permissionId"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type JwtService struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewJwtService(secret string, ttl time.Duration) *JwtService {
|
||||
return &JwtService{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
// Sign returns a signed jwt token for the given share.
|
||||
func (m *JwtService) Sign(shareID string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := jwtClaims{
|
||||
ShareID: shareID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func parseClaims(t *testing.T, m *JwtService, tokenString string) (*jwtClaims, error) {
|
||||
t.Helper()
|
||||
c := &jwtClaims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, c, func(*jwt.Token) (any, error) {
|
||||
return m.secret, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
return c, err
|
||||
}
|
||||
|
||||
func TestSignAndParse(t *testing.T) {
|
||||
m := NewJwtService("test-secret", time.Hour)
|
||||
|
||||
tok, err := m.Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, tok)
|
||||
|
||||
claims, err := parseClaims(t, m, tok)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "share-id", claims.ShareID)
|
||||
}
|
||||
|
||||
func TestParseExpired(t *testing.T) {
|
||||
m := NewJwtService("test-secret", -time.Minute)
|
||||
|
||||
tok, err := m.Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = parseClaims(t, m, tok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/google/renameio/v2"
|
||||
)
|
||||
|
||||
func NewFileManager(root string) *FileManager {
|
||||
return &FileManager{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
type FileManager struct {
|
||||
root string
|
||||
}
|
||||
|
||||
const dirPerm = 0700
|
||||
const filePerm = 0600
|
||||
|
||||
// minHashLength is the minimum share id hash length needed to derive a path.
|
||||
const minHashLength = 4
|
||||
|
||||
func (s *FileManager) Add(rec Record) error {
|
||||
lock, err := s.lockRecord(rec.ShareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = lock.Unlock() }()
|
||||
|
||||
p, err := s.path(rec.ShareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("record %q already exists: %w", rec.ShareIDHash, fs.ErrExist)
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.add(rec)
|
||||
}
|
||||
|
||||
// Get returns the record for the given share id hash.
|
||||
func (s *FileManager) Get(shareIDHash string) (Record, error) {
|
||||
return s.get(shareIDHash)
|
||||
}
|
||||
|
||||
func (s *FileManager) Remove(shareIDHash string) error {
|
||||
lock, err := s.lockRecord(shareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = lock.Unlock()
|
||||
_ = os.Remove(lock.Path())
|
||||
}()
|
||||
|
||||
p, err := s.path(shareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(p); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileManager) Redeem(shareIDHash string) error {
|
||||
lock, err := s.lockRecord(shareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = lock.Unlock() }()
|
||||
|
||||
rec, err := s.get(shareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rec.Redeemed {
|
||||
return ErrAlreadyRedeemed
|
||||
}
|
||||
|
||||
rec.Redeemed = true
|
||||
return s.add(rec)
|
||||
}
|
||||
|
||||
func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) {
|
||||
p, err := s.path(shareIDHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(p), dirPerm); err != nil {
|
||||
return nil, fmt.Errorf("could not create directory %s: %w", filepath.Dir(p), err)
|
||||
}
|
||||
|
||||
lock := flock.New(p + ".lock")
|
||||
if err := lock.Lock(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (s *FileManager) add(rec Record) error {
|
||||
p, err := s.path(rec.ShareIDHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(p)
|
||||
if err := os.MkdirAll(dir, dirPerm); err != nil {
|
||||
return fmt.Errorf("could not create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
return renameio.WriteFile(p, data, filePerm)
|
||||
}
|
||||
|
||||
func (s *FileManager) get(shareIDHash string) (Record, error) {
|
||||
p, err := s.path(shareIDHash)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
return Record{}, err
|
||||
}
|
||||
|
||||
rec := Record{}
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *FileManager) path(shareIDHash string) (string, error) {
|
||||
if len(shareIDHash) < minHashLength {
|
||||
return "", ErrInvalidHash
|
||||
}
|
||||
|
||||
p := filepath.Join(s.root, shareIDHash[:2], shareIDHash[2:4], shareIDHash[4:]+".json")
|
||||
root := filepath.Clean(s.root)
|
||||
if !strings.HasPrefix(p, root+string(os.PathSeparator)) {
|
||||
return "", ErrInvalidHash
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRecord(shareID string) Record {
|
||||
svc := token.NewTokenService()
|
||||
tok, _ := svc.Generate(shareID)
|
||||
|
||||
return Record{
|
||||
ShareID: shareID,
|
||||
ShareIDHash: tok.ShareIDHash,
|
||||
SecretHash: tok.SecretHash(),
|
||||
Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerAddGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, got)
|
||||
}
|
||||
|
||||
func TestFileManagerGetMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
_, err := s.Get("doesnotexist")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerAddExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
rec.SecretHash = "other"
|
||||
require.ErrorIs(t, s.Add(rec), fs.ErrExist)
|
||||
}
|
||||
|
||||
func TestFileManagerInvalidHash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
_, err := s.Get("ab")
|
||||
require.ErrorIs(t, err, ErrInvalidHash)
|
||||
|
||||
_, err = s.Get("../../etc/passwd-xyz")
|
||||
require.ErrorIs(t, err, ErrInvalidHash)
|
||||
|
||||
require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash)
|
||||
require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash)
|
||||
}
|
||||
|
||||
func TestFileManagerRemove(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
require.NoError(t, s.Remove(rec.ShareIDHash))
|
||||
|
||||
_, err := s.Get(rec.ShareIDHash)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerRemoveMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
err := s.Remove("doesnotexist")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerRedeem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
require.NoError(t, s.Redeem(rec.ShareIDHash))
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.Redeemed)
|
||||
|
||||
err = s.Redeem(rec.ShareIDHash)
|
||||
assert.ErrorIs(t, err, ErrAlreadyRedeemed)
|
||||
}
|
||||
|
||||
func TestFileManagerRedeemMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
err := s.Redeem("doesnotexist")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerAddConcurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
|
||||
const workers = 20
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
success atomic.Int32
|
||||
)
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := s.Add(rec); err == nil {
|
||||
success.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(1), success.Load())
|
||||
}
|
||||
|
||||
func TestFileManagerRedeemConcurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
const workers = 20
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
success atomic.Int32
|
||||
)
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := s.Redeem(rec.ShareIDHash); err == nil {
|
||||
success.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(1), success.Load())
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewManager creates a new instance of Manager. 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 NewManager(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Manager {
|
||||
mock := &Manager{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Manager is an autogenerated mock type for the Manager type
|
||||
type Manager struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Manager_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Manager) EXPECT() *Manager_Expecter {
|
||||
return &Manager_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Add provides a mock function for the type Manager
|
||||
func (_mock *Manager) Add(rec storage.Record) error {
|
||||
ret := _mock.Called(rec)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Add")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(storage.Record) error); ok {
|
||||
r0 = returnFunc(rec)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Manager_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add'
|
||||
type Manager_Add_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Add is a helper method to define mock.On call
|
||||
// - rec storage.Record
|
||||
func (_e *Manager_Expecter) Add(rec any) *Manager_Add_Call {
|
||||
return &Manager_Add_Call{Call: _e.mock.On("Add", rec)}
|
||||
}
|
||||
|
||||
func (_c *Manager_Add_Call) Run(run func(rec storage.Record)) *Manager_Add_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 storage.Record
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(storage.Record)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Add_Call) Return(err error) *Manager_Add_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Add_Call) RunAndReturn(run func(rec storage.Record) error) *Manager_Add_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Get provides a mock function for the type Manager
|
||||
func (_mock *Manager) Get(shareIDHash string) (storage.Record, error) {
|
||||
ret := _mock.Called(shareIDHash)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Get")
|
||||
}
|
||||
|
||||
var r0 storage.Record
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) (storage.Record, error)); ok {
|
||||
return returnFunc(shareIDHash)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(string) storage.Record); ok {
|
||||
r0 = returnFunc(shareIDHash)
|
||||
} else {
|
||||
r0 = ret.Get(0).(storage.Record)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = returnFunc(shareIDHash)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Manager_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
|
||||
type Manager_Get_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Get is a helper method to define mock.On call
|
||||
// - shareIDHash string
|
||||
func (_e *Manager_Expecter) Get(shareIDHash any) *Manager_Get_Call {
|
||||
return &Manager_Get_Call{Call: _e.mock.On("Get", shareIDHash)}
|
||||
}
|
||||
|
||||
func (_c *Manager_Get_Call) Run(run func(shareIDHash string)) *Manager_Get_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Get_Call) Return(record storage.Record, err error) *Manager_Get_Call {
|
||||
_c.Call.Return(record, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (storage.Record, error)) *Manager_Get_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Redeem provides a mock function for the type Manager
|
||||
func (_mock *Manager) Redeem(shareIDHash string) error {
|
||||
ret := _mock.Called(shareIDHash)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Redeem")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(shareIDHash)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Manager_Redeem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Redeem'
|
||||
type Manager_Redeem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Redeem is a helper method to define mock.On call
|
||||
// - shareIDHash string
|
||||
func (_e *Manager_Expecter) Redeem(shareIDHash any) *Manager_Redeem_Call {
|
||||
return &Manager_Redeem_Call{Call: _e.mock.On("Redeem", shareIDHash)}
|
||||
}
|
||||
|
||||
func (_c *Manager_Redeem_Call) Run(run func(shareIDHash string)) *Manager_Redeem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Redeem_Call) Return(err error) *Manager_Redeem_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Redeem_Call) RunAndReturn(run func(shareIDHash string) error) *Manager_Redeem_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Remove provides a mock function for the type Manager
|
||||
func (_mock *Manager) Remove(shareIDHash string) error {
|
||||
ret := _mock.Called(shareIDHash)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Remove")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(shareIDHash)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Manager_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
|
||||
type Manager_Remove_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Remove is a helper method to define mock.On call
|
||||
// - shareIDHash string
|
||||
func (_e *Manager_Expecter) Remove(shareIDHash any) *Manager_Remove_Call {
|
||||
return &Manager_Remove_Call{Call: _e.mock.On("Remove", shareIDHash)}
|
||||
}
|
||||
|
||||
func (_c *Manager_Remove_Call) Run(run func(shareIDHash string)) *Manager_Remove_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Remove_Call) Return(err error) *Manager_Remove_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Remove_Call) RunAndReturn(run func(shareIDHash string) error) *Manager_Remove_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("record not found")
|
||||
var ErrAlreadyRedeemed = errors.New("token already redeemed")
|
||||
var ErrInvalidHash = errors.New("invalid share id hash")
|
||||
|
||||
// Record holds the data persisted for a guest share token.
|
||||
type Record struct {
|
||||
ShareID string `json:"shareid"`
|
||||
ShareIDHash string `json:"shareidhash"`
|
||||
SecretHash string `json:"secrethash"`
|
||||
Expiry time.Time `json:"expiry,omitzero"`
|
||||
Redeemed bool `json:"redeemed"`
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
Add(rec Record) error
|
||||
Get(shareIDHash string) (Record, error)
|
||||
Remove(shareIDHash string) error
|
||||
Redeem(shareIDHash string) error
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenVersion = "v1"
|
||||
secretLength = 32
|
||||
tokenParts = 3
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
type Token struct {
|
||||
ShareIDHash string
|
||||
secret string
|
||||
}
|
||||
|
||||
func (t *Token) String() string {
|
||||
return strings.Join([]string{tokenVersion, t.ShareIDHash, t.secret}, ".")
|
||||
}
|
||||
|
||||
func (t *Token) SecretHash() string {
|
||||
return Hash(t.secret)
|
||||
}
|
||||
|
||||
type TokenService struct{}
|
||||
|
||||
func NewTokenService() *TokenService {
|
||||
return &TokenService{}
|
||||
}
|
||||
|
||||
func (s *TokenService) Generate(shareID string) (*Token, error) {
|
||||
secretBytes := make([]byte, secretLength)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return nil, fmt.Errorf("could not generate random secret: %w", err)
|
||||
}
|
||||
|
||||
secret := base64.RawURLEncoding.EncodeToString(secretBytes)
|
||||
return &Token{
|
||||
ShareIDHash: Hash(shareID),
|
||||
secret: secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TokenService) Parse(encoded string) (*Token, error) {
|
||||
parts := strings.Split(encoded, ".")
|
||||
if len(parts) != tokenParts || parts[0] != tokenVersion || parts[1] == "" || parts[2] == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return &Token{
|
||||
ShareIDHash: parts[1],
|
||||
secret: parts[2],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TokenService) Verify(candidate Token, storedSecretHash string) error {
|
||||
if candidate.ShareIDHash == "" || candidate.secret == "" || candidate.SecretHash() != storedSecretHash {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Hash(value string) string {
|
||||
h := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testShareID = "e0123456-7890-abcd-ef01-234567890abc"
|
||||
|
||||
func TestGenerateAndString(t *testing.T) {
|
||||
svc := NewTokenService()
|
||||
|
||||
tok, err := svc.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Hash(testShareID), tok.ShareIDHash)
|
||||
assert.NotEmpty(t, tok.SecretHash())
|
||||
assert.NotEmpty(t, tok.String())
|
||||
}
|
||||
|
||||
func TestGenerateRandomizesSecret(t *testing.T) {
|
||||
svc := NewTokenService()
|
||||
|
||||
tok1, err := svc.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
tok2, err := svc.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tok1.ShareIDHash, tok2.ShareIDHash)
|
||||
assert.NotEqual(t, tok1.SecretHash(), tok2.SecretHash())
|
||||
assert.NotEqual(t, tok1.String(), tok2.String())
|
||||
|
||||
other, err := svc.Generate("9f9f9f9-9f9f-9f9f-9f9f-9f9f9f9f9f9f")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, tok1.ShareIDHash, other.ShareIDHash)
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
svc := NewTokenService()
|
||||
original, err := svc.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", encoded: original.String()},
|
||||
{name: "wrong version", encoded: "v2." + original.ShareIDHash + "." + original.secret, wantErr: true},
|
||||
{name: "missing version", encoded: original.ShareIDHash + "." + original.secret, wantErr: true},
|
||||
{name: "too many parts", encoded: original.String() + ".extra", wantErr: true},
|
||||
{name: "empty share hash", encoded: "v1.." + original.secret, wantErr: true},
|
||||
{name: "empty secret", encoded: "v1." + original.ShareIDHash + ".", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, err := svc.Parse(tt.encoded)
|
||||
if tt.wantErr {
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original.ShareIDHash, parsed.ShareIDHash)
|
||||
assert.Equal(t, original.SecretHash(), parsed.SecretHash())
|
||||
assert.Equal(t, original.String(), parsed.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
svc := NewTokenService()
|
||||
tok, err := svc.Generate(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token Token
|
||||
storedSecretHash string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", token: *tok, storedSecretHash: tok.SecretHash()},
|
||||
{name: "wrong stored secret", token: *tok, storedSecretHash: Hash("other-secret"), wantErr: true},
|
||||
{name: "missing fields", token: Token{ShareIDHash: tok.ShareIDHash}, storedSecretHash: tok.SecretHash(), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := svc.Verify(tt.token, tt.storedSecretHash)
|
||||
if tt.wantErr {
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,13 @@ type Config struct {
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
|
||||
Application Application `yaml:"application"`
|
||||
Spaces Spaces `yaml:"spaces"`
|
||||
Identity Identity `yaml:"identity"`
|
||||
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"`
|
||||
EnableGuestInvites bool `yaml:"enable_guest_invites" env:"GRAPH_ENABLE_GUEST_INVITES" desc:"Enables creating permission invites (shares) to mail addresses. Disabled by default." introductionVersion:"8.1.0"`
|
||||
Events Events `yaml:"events"`
|
||||
UnifiedRoles UnifiedRoles `yaml:"unified_roles"`
|
||||
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"`
|
||||
Application Application `yaml:"application"`
|
||||
Spaces Spaces `yaml:"spaces"`
|
||||
Identity Identity `yaml:"identity"`
|
||||
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"`
|
||||
Events Events `yaml:"events"`
|
||||
UnifiedRoles UnifiedRoles `yaml:"unified_roles"`
|
||||
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"`
|
||||
|
||||
Keycloak Keycloak `yaml:"keycloak"`
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
@@ -75,8 +75,7 @@ func DefaultConfig() *config.Config {
|
||||
AssignDefaultUserRole: true,
|
||||
IdentitySearchMinLength: 3,
|
||||
},
|
||||
EnableGuestInvites: false,
|
||||
Reva: shared.DefaultRevaConfig(),
|
||||
Reva: shared.DefaultRevaConfig(),
|
||||
Spaces: config.Spaces{
|
||||
StorageUsersAddress: "eu.opencloud.api.storage-users",
|
||||
WebDavBase: "https://localhost:9200",
|
||||
|
||||
@@ -164,7 +164,7 @@ func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *sto
|
||||
var expiration *types.Timestamp
|
||||
var cTime *types.Timestamp
|
||||
if email := driveRecipient.GetEmail(); email != "" {
|
||||
if !s.config.EnableGuestInvites {
|
||||
if s.config.Commons == nil || !s.config.Commons.EnableGuestLinks {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.NotSupported, "sharing with mail recipients is not enabled")
|
||||
}
|
||||
createShareRequest := createShareRequestToMail(email, statResponse.GetInfo(), cs3ResourcePermissions)
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/mocks"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
|
||||
@@ -168,7 +169,7 @@ var _ = Describe("DriveItemPermissionsService", func() {
|
||||
})
|
||||
|
||||
It("creates guest share using an email address", func() {
|
||||
cfg.EnableGuestInvites = true
|
||||
cfg.Commons = &shared.Commons{EnableGuestLinks: true}
|
||||
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
|
||||
gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil)
|
||||
driveItemInvite.Recipients = []libregraph.DriveRecipient{
|
||||
|
||||
@@ -283,6 +283,11 @@ func DefaultPolicies() []config.Policy {
|
||||
Endpoint: "/graph/v1.0/invitations",
|
||||
Service: "eu.opencloud.web.invitations",
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/v1beta1/extensions/org.libregraph/guestLinks",
|
||||
Service: "eu.opencloud.web.auth-guest",
|
||||
Unprotected: true,
|
||||
},
|
||||
{
|
||||
Endpoint: "/graph/",
|
||||
Service: "eu.opencloud.web.graph",
|
||||
|
||||
Reference in new issue
Block a user