init
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.env
|
||||
data
|
||||
bin
|
||||
*.log
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Public URL used in signed hybrid/online licenses.
|
||||
LICENSE_PUBLIC_URL=http://localhost:8091
|
||||
LICENSE_ADDRESS=:8091
|
||||
LICENSE_BRAND=Universal License Platform
|
||||
LICENSE_DATA_FILE=/data/platform.json
|
||||
|
||||
# Generate once: openssl rand -base64 32
|
||||
# Never rotate this value without decrypting and re-encrypting the data store.
|
||||
LICENSE_MASTER_KEY=
|
||||
# Or use Docker/Kubernetes secrets:
|
||||
# LICENSE_MASTER_KEY_FILE=/run/secrets/license_master_key
|
||||
|
||||
# Used only while the data store has no administrator account.
|
||||
LICENSE_BOOTSTRAP_ADMIN_USER=admin
|
||||
LICENSE_BOOTSTRAP_ADMIN_NAME=Administrator
|
||||
LICENSE_BOOTSTRAP_ADMIN_PASSWORD=
|
||||
# LICENSE_BOOTSTRAP_ADMIN_PASSWORD_FILE=/run/secrets/bootstrap_password
|
||||
|
||||
# Optional bearer token for the management API. Use at least 32 random bytes.
|
||||
LICENSE_ADMIN_API_TOKEN=
|
||||
# LICENSE_ADMIN_API_TOKEN_FILE=/run/secrets/admin_api_token
|
||||
|
||||
LICENSE_SESSION_TTL=12h
|
||||
LICENSE_DEFAULT_LEASE_TTL=1h
|
||||
LICENSE_MAX_LEASE_TTL=24h
|
||||
LICENSE_SECURE_COOKIES=false
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
data/
|
||||
bin/
|
||||
*.log
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go test ./... && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/license-platform ./cmd/server && \
|
||||
mkdir -p /out/data
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/license-platform /app/license-platform
|
||||
COPY --from=build --chown=nonroot:nonroot /out/data /data
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8091
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app/license-platform"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AI Usage Disclosure contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
19
Makefile
Normal file
19
Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
.PHONY: run test check build docker-build
|
||||
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
check:
|
||||
gofmt -w $$(find . -name '*.go' -type f)
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/license-platform ./cmd/server
|
||||
|
||||
docker-build:
|
||||
docker build -t universal-license-platform:1.0.0-local .
|
||||
162
README.md
162
README.md
@@ -1,2 +1,162 @@
|
||||
# license-managent-system
|
||||
# Universal License Platform
|
||||
|
||||
Eigenständiger Lizenz-Server mit drei getrennten Portalen, signierten Offline-/Hybrid-/Online-Lizenzen und einer wiederverwendbaren Go-Clientbibliothek.
|
||||
|
||||
## Enthalten
|
||||
|
||||
- **Admin / Management:** initialisiert die Write-once-Schlüssel, verwaltet Accounts, sieht alle Lizenzen und den Audit-Trail.
|
||||
- **Reseller / Autor:** stellt Lizenzen aus und verwaltet ausschließlich die eigenen Datensätze, ohne Zugriff auf private Schlüssel.
|
||||
- **Kunde / Client:** sieht die zugeordneten Lizenzen, Laufzeiten, Status und fertige Client-Konfigurationen.
|
||||
- **Validierungs-API:** validiert signierte Lizenzen, prüft Sperrstatus und stellt kurzlebige, Ed25519-signierte Leases aus.
|
||||
- **Hybrid offline:** ein Client verwendet bei temporärer Nichterreichbarkeit eine zuvor verifizierte Lease bis zum signierten Grace-Ende.
|
||||
- **Client-SDK:** `sdk/go/licenseclient` für Feature Gates, Limits, Hintergrund-Refresh und sicheren Lease-Cache.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Issuer- und Lease-Private-Keys werden mit **AES-256-GCM** verschlüsselt im Datenspeicher abgelegt.
|
||||
- Lizenz-Tokens werden ebenfalls verschlüsselt gespeichert; zur Online-Prüfung wird zusätzlich nur ein SHA-256-Hash gebunden.
|
||||
- Die Schlüsselerzeugung ist **write-once**. Sobald Schlüssel existieren, verschwindet der Button und das Backend lehnt jeden weiteren Generierungsversuch ab.
|
||||
- Passwörter werden mit PBKDF2-HMAC-SHA256 und individuellem Salt gespeichert.
|
||||
- Sessions sind HttpOnly, SameSite=Strict, zeitlich begrenzt und CSRF-geschützt.
|
||||
- Login und Validierungs-API besitzen einfache Rate Limits.
|
||||
- Sicherheitsheader, restriktive CSP und ein persistenter Audit-Trail sind aktiviert.
|
||||
- Reseller- und Kundenzugriffe werden serverseitig nach Eigentum bzw. Zuordnung gefiltert.
|
||||
|
||||
> Der JSON-Datenspeicher ist atomar und mit Dateimodus `0600` geschrieben, aber für eine einzelne Serverinstanz gedacht. Vor Active/Active-Betrieb sollte `Store` durch PostgreSQL oder eine andere transaktionale Datenbank ersetzt werden.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
cd license-platform
|
||||
cp .env.example .env
|
||||
openssl rand -base64 32 # als LICENSE_MASTER_KEY eintragen
|
||||
openssl rand -base64 32 # als LICENSE_ADMIN_API_TOKEN eintragen
|
||||
# Ein langes Bootstrap-Passwort in LICENSE_BOOTSTRAP_ADMIN_PASSWORD eintragen.
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Danach `http://localhost:8091` öffnen und mit dem Bootstrap-Admin anmelden. Der Bootstrap-Account wird nur angelegt, wenn der Datenspeicher noch keinen Administrator enthält.
|
||||
|
||||
Im Admin-Portal werden die beiden Schlüsselpaare **einmalig** erzeugt. Private Schlüssel werden nie in der Oberfläche angezeigt.
|
||||
|
||||
## Server-URL und automatische Erkennung
|
||||
|
||||
Für Hybrid- und Online-Lizenzen schreibt die Plattform `LICENSE_PUBLIC_URL` signiert in `verification.serverUrl`. Das Go-SDK löst die URL in dieser Reihenfolge auf:
|
||||
|
||||
1. `licenseclient.Config.ServerURL`
|
||||
2. Environment `LICENSE_SERVER_URL`
|
||||
3. signierte `verification.serverUrl` aus der Lizenz
|
||||
4. `/.well-known/license-server` relativ zur Produkt-Base-URL
|
||||
|
||||
Damit kann die URL weiterhin per ENV überschrieben werden, muss bei üblichen Installationen aber nicht doppelt gepflegt werden.
|
||||
|
||||
## Clientbibliothek
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
|
||||
)
|
||||
|
||||
//go:embed trusted-keys.json
|
||||
var trustedKeys []byte
|
||||
|
||||
func main() {
|
||||
trust, err := licensekit.ParseTrustStore(trustedKeys)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
client := licenseclient.New(context.Background(), licenseclient.Config{
|
||||
Product: "my-product", // im Produkt fest verdrahten
|
||||
ClientVersion: "2.1.0",
|
||||
Token: os.Getenv("LICENSE_TOKEN"),
|
||||
TrustStore: trust, // nur Public Keys einbetten
|
||||
BaseURL: "https://app.example.org",
|
||||
InstanceID: os.Getenv("LICENSE_INSTANCE_ID"),
|
||||
Mode: licensekit.ModeHybrid,
|
||||
CacheFile: "/data/license-lease.json",
|
||||
RefreshEvery: 15 * time.Minute,
|
||||
RequestTimeout: 5 * time.Second,
|
||||
// ServerURL ist optional: ENV oder signierter Token werden erkannt.
|
||||
})
|
||||
client.Start(context.Background())
|
||||
defer client.Close()
|
||||
|
||||
if client.Has("advanced_export") {
|
||||
// Feature freischalten
|
||||
}
|
||||
if users, ok := client.Limit("users"); ok {
|
||||
_ = users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Der Trust Store kann nach der Initialisierung unter `GET /api/v1/trust-store` geladen und in das Clientprodukt eingebettet werden. Ein Trust Store darf niemals kundenseitig frei konfigurierbar sein.
|
||||
|
||||
## API
|
||||
|
||||
Öffentlich:
|
||||
|
||||
- `GET /.well-known/license-server`
|
||||
- `GET /api/v1/trust-store`
|
||||
- `POST /api/v1/licenses/validate`
|
||||
- Kompatibilitätsalias: `POST /v1/introspect`
|
||||
|
||||
Management mit `Authorization: Bearer $LICENSE_ADMIN_API_TOKEN`:
|
||||
|
||||
- `GET /api/v1/licenses`
|
||||
- `POST /api/v1/licenses`
|
||||
- `POST /api/v1/licenses/import` for already signed tokens
|
||||
- `POST /api/v1/licenses/{id}/revoke`
|
||||
- `POST /api/v1/licenses/{id}/restore`
|
||||
|
||||
Beispiel zur Validierung:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:8091/api/v1/licenses/validate \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"token":"LICENSE_TOKEN",
|
||||
"product":"my-product",
|
||||
"baseUrl":"https://app.example.org",
|
||||
"instanceId":"optional-instance"
|
||||
}'
|
||||
```
|
||||
|
||||
Die vollständige Beschreibung liegt in [`openapi.yaml`](openapi.yaml). Die bisherigen `/v1/admin/licenses`-Routen bleiben als Kompatibilitätsalias verfügbar, sodass das ältere `licenseweb` bestehende Tokens registrieren kann.
|
||||
|
||||
Eine schrittweise Übernahme vorhandener Schlüssel und Lizenzen ist in [`docs/MIGRATION.md`](docs/MIGRATION.md) beschrieben.
|
||||
|
||||
## ENV-Variablen
|
||||
|
||||
| Variable | Zweck |
|
||||
|---|---|
|
||||
| `LICENSE_PUBLIC_URL` | Öffentliche Basis-URL; wird in Hybrid-/Online-Lizenzen signiert |
|
||||
| `LICENSE_MASTER_KEY` / `_FILE` | Base64-kodierter 32-Byte-Schlüssel für AES-256-GCM |
|
||||
| `LICENSE_BOOTSTRAP_ADMIN_USER` | initialer Admin-Benutzername |
|
||||
| `LICENSE_BOOTSTRAP_ADMIN_PASSWORD` / `_FILE` | initiales Passwort, mindestens 12 Zeichen |
|
||||
| `LICENSE_ADMIN_API_TOKEN` / `_FILE` | optionaler Management-API-Bearer |
|
||||
| `LICENSE_DATA_FILE` | persistenter JSON-Datenspeicher |
|
||||
| `LICENSE_SESSION_TTL` | Session-Laufzeit, Standard `12h` |
|
||||
| `LICENSE_DEFAULT_LEASE_TTL` | Standard-Lease, `1h` |
|
||||
| `LICENSE_MAX_LEASE_TTL` | serverseitiges Maximum, `24h` |
|
||||
| `LICENSE_SECURE_COOKIES` | bei TLS `true`; HTTPS-URL aktiviert es automatisch |
|
||||
|
||||
## Betrieb
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
go build ./cmd/server
|
||||
```
|
||||
|
||||
Für produktive Installationen gehören `LICENSE_MASTER_KEY`, Bootstrap-Passwort und API-Token in Docker/Kubernetes Secrets. TLS sollte am Reverse Proxy terminiert werden; `LICENSE_PUBLIC_URL` muss dabei die externe HTTPS-URL enthalten.
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security notes
|
||||
|
||||
- Never commit `.env`, the data store, `LICENSE_MASTER_KEY`, bootstrap credentials or API tokens.
|
||||
- Back up the encrypted data store together with the master key in separate, access-controlled locations.
|
||||
- Losing the master key makes private signing keys and stored license tokens unrecoverable.
|
||||
- Do not change the master key in place. A controlled decrypt/re-encrypt migration is required.
|
||||
- Publish the service only behind TLS and set `LICENSE_PUBLIC_URL` to the external HTTPS URL.
|
||||
- Rotate user passwords and management API tokens independently of signing keys.
|
||||
- Signing-key rotation is intentionally not an overwrite operation. Introduce a versioned key-rotation workflow and retain previous public keys until all old licenses expire.
|
||||
- The bundled JSON store is single-node. Use a transactional shared store before horizontal scaling.
|
||||
137
cmd/server/main.go
Normal file
137
cmd/server/main.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/internal/platform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
|
||||
healthcheck()
|
||||
return
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
store, err := platform.OpenStore(env("LICENSE_DATA_FILE", "./data/platform.json"))
|
||||
if err != nil {
|
||||
logger.Error("open data store", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
app, err := platform.New(platform.Config{
|
||||
Brand: env("LICENSE_BRAND", "License Platform"),
|
||||
Address: env("LICENSE_ADDRESS", ":8090"),
|
||||
PublicURL: env("LICENSE_PUBLIC_URL", "http://localhost:8090"),
|
||||
DataFile: env("LICENSE_DATA_FILE", "./data/platform.json"),
|
||||
MasterKey: secretEnv("LICENSE_MASTER_KEY"),
|
||||
BootstrapUsername: env("LICENSE_BOOTSTRAP_ADMIN_USER", "admin"),
|
||||
BootstrapPassword: secretEnv("LICENSE_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
BootstrapName: env("LICENSE_BOOTSTRAP_ADMIN_NAME", "Administrator"),
|
||||
AdminAPIToken: firstSecret("LICENSE_ADMIN_API_TOKEN", "LICENSE_SERVER_ADMIN_TOKEN"),
|
||||
SessionTTL: durationEnv("LICENSE_SESSION_TTL", 12*time.Hour),
|
||||
DefaultLeaseTTL: durationEnv("LICENSE_DEFAULT_LEASE_TTL", time.Hour),
|
||||
MaxLeaseTTL: durationEnv("LICENSE_MAX_LEASE_TTL", 24*time.Hour),
|
||||
SecureCookies: boolEnv("LICENSE_SECURE_COOKIES", false),
|
||||
}, store, logger)
|
||||
if err != nil {
|
||||
logger.Error("create license platform", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
address := env("LICENSE_ADDRESS", ":8090")
|
||||
server := &http.Server{Addr: address, Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
logger.Info("license platform started", "address", address, "public_url", env("LICENSE_PUBLIC_URL", "http://localhost:8090"))
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("license platform failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func secretEnv(key string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnv(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func healthcheck() {
|
||||
address := env("LICENSE_ADDRESS", ":8090")
|
||||
port := address
|
||||
if index := strings.LastIndex(address, ":"); index >= 0 {
|
||||
port = address[index:]
|
||||
}
|
||||
if !strings.HasPrefix(port, ":") {
|
||||
port = ":" + port
|
||||
}
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://127.0.0.1" + port + "/healthz")
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func firstSecret(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := secretEnv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
30
compose.yaml
Normal file
30
compose.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
license-platform:
|
||||
build: .
|
||||
image: universal-license-platform:1.0.0-local
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LICENSE_ADDRESS: :8090
|
||||
LICENSE_DATA_FILE: /data/platform.json
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- license-platform-data:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "/app/license-platform", "--healthcheck"]
|
||||
interval: 20s
|
||||
timeout: 4s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
license-platform-data:
|
||||
23
docs/ARCHITECTURE.md
Normal file
23
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Architecture
|
||||
|
||||
```text
|
||||
Product + Go SDK
|
||||
├─ local Ed25519 license verification
|
||||
├─ feature/limit gates
|
||||
├─ signed lease cache (hybrid offline)
|
||||
└─ POST /api/v1/licenses/validate
|
||||
│
|
||||
▼
|
||||
License Platform
|
||||
├─ Admin / Management portal
|
||||
├─ Reseller / Author portal
|
||||
├─ Customer / Client portal
|
||||
├─ encrypted issuer + lease key vault
|
||||
├─ encrypted license-token registry
|
||||
├─ revocation and lease service
|
||||
└─ audit trail / role isolation
|
||||
```
|
||||
|
||||
The issuer key signs long-lived product licenses. The lease key signs short-lived runtime confirmations. Clients embed only public keys. The management UI never returns either private key.
|
||||
|
||||
The platform intentionally uses a `Store` boundary. The included JSON implementation is safe for one process and atomic file replacement. A database implementation can preserve the portal and API layers while adding transactions, replication and tenant indexing.
|
||||
14
docs/MIGRATION.md
Normal file
14
docs/MIGRATION.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Migration from the embedded reference server
|
||||
|
||||
1. Deploy the standalone platform with a persistent data volume, a new `LICENSE_MASTER_KEY`, and bootstrap credentials.
|
||||
2. Sign in as administrator.
|
||||
3. In **Schlüsselverwaltung**, open **Bestehende Schlüsselpaare sicher importieren** and provide the existing issuer and lease private keys plus their original key IDs. This action is write-once.
|
||||
4. Download `GET /api/v1/trust-store` and update the public trust store embedded in each product build.
|
||||
5. Register existing, still-valid license tokens through one of these routes:
|
||||
- `POST /api/v1/licenses/import` with `{"token":"...","customerUserId":"optional"}`;
|
||||
- legacy-compatible `POST /v1/admin/licenses` with `{"token":"..."}`;
|
||||
- the existing local `licenseweb`, pointed at the new platform URL and management bearer token.
|
||||
6. Set `LICENSE_PUBLIC_URL` to the stable external HTTPS URL. Newly issued hybrid/online licenses will contain this URL as a signed claim.
|
||||
7. Existing licenses without `verification.serverUrl` continue to work when clients receive `LICENSE_SERVER_URL` or well-known discovery.
|
||||
|
||||
Do not generate new keys when migrating existing licenses. A new issuer key would make old tokens unverifiable unless both old and new public keys are retained in the product trust store.
|
||||
129
internal/platform/auth.go
Normal file
129
internal/platform/auth.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "lp_session"
|
||||
loginCSRFCookie = "lp_login_csrf"
|
||||
)
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (User, Session, bool) {
|
||||
cookie, err := r.Cookie(sessionCookie)
|
||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
session, ok := s.store.GetSession(tokenHash(cookie.Value), time.Now().UTC())
|
||||
if !ok {
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
user, ok := s.store.GetUser(session.UserID)
|
||||
if !ok || !user.Active {
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
return user, session, true
|
||||
}
|
||||
|
||||
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (User, Session, bool) {
|
||||
user, session, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
return user, session, true
|
||||
}
|
||||
|
||||
func (s *Server) requireRole(w http.ResponseWriter, r *http.Request, roles ...Role) (User, Session, bool) {
|
||||
user, session, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
for _, role := range roles {
|
||||
if user.Role == role {
|
||||
return user, session, true
|
||||
}
|
||||
}
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return User{}, Session{}, false
|
||||
}
|
||||
|
||||
func (s *Server) verifyCSRF(r *http.Request, session Session) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false
|
||||
}
|
||||
return constantTokenEqual(session.CSRFToken, r.FormValue("csrf"))
|
||||
}
|
||||
|
||||
func (s *Server) newSession(user User) (string, Session, error) {
|
||||
raw, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", Session{}, err
|
||||
}
|
||||
csrf, err := randomToken(24)
|
||||
if err != nil {
|
||||
return "", Session{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
session := Session{IDHash: tokenHash(raw), UserID: user.ID, CSRFToken: csrf, CreatedAt: now.Unix(), ExpiresAt: now.Add(s.cfg.SessionTTL).Unix()}
|
||||
if err := s.store.CreateSession(session); err != nil {
|
||||
return "", Session{}, err
|
||||
}
|
||||
return raw, session, nil
|
||||
}
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0)})
|
||||
}
|
||||
|
||||
func (s *Server) loginCSRF(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(loginCSRFCookie); err == nil && len(cookie.Value) >= 20 {
|
||||
return cookie.Value
|
||||
}
|
||||
value, _ := randomToken(24)
|
||||
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookie, Value: value, Path: "/login", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: 600})
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) checkLoginCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(loginCSRFCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return constantTokenEqual(cookie.Value, r.FormValue("csrf"))
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(value) < 8 || !strings.EqualFold(value[:7], "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value[7:])
|
||||
}
|
||||
|
||||
func (s *Server) apiActor(r *http.Request) (User, error) {
|
||||
if token := bearerToken(r); token != "" && constantTokenEqual(s.cfg.AdminAPIToken, token) {
|
||||
return User{ID: "api_admin", Username: "api", DisplayName: "Management API", Role: RoleAdmin, Active: true}, nil
|
||||
}
|
||||
user, _, ok := s.currentUser(r)
|
||||
if ok && (user.Role == RoleAdmin || user.Role == RoleReseller) {
|
||||
return user, nil
|
||||
}
|
||||
return User{}, errors.New("unauthorized")
|
||||
}
|
||||
57
internal/platform/config.go
Normal file
57
internal/platform/config.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Brand string
|
||||
Address string
|
||||
PublicURL string
|
||||
DataFile string
|
||||
MasterKey string
|
||||
BootstrapUsername string
|
||||
BootstrapPassword string
|
||||
BootstrapName string
|
||||
AdminAPIToken string
|
||||
SessionTTL time.Duration
|
||||
DefaultLeaseTTL time.Duration
|
||||
MaxLeaseTTL time.Duration
|
||||
SecureCookies bool
|
||||
}
|
||||
|
||||
func (c *Config) normalize() error {
|
||||
if strings.TrimSpace(c.Brand) == "" {
|
||||
c.Brand = "License Platform"
|
||||
}
|
||||
if strings.TrimSpace(c.Address) == "" {
|
||||
c.Address = ":8091"
|
||||
}
|
||||
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
|
||||
if c.PublicURL == "" {
|
||||
return errors.New("LICENSE_PUBLIC_URL is required")
|
||||
}
|
||||
parsed, err := url.Parse(c.PublicURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return errors.New("LICENSE_PUBLIC_URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if token := strings.TrimSpace(c.AdminAPIToken); token != "" && len(token) < 32 {
|
||||
return errors.New("LICENSE_ADMIN_API_TOKEN must contain at least 32 characters when configured")
|
||||
}
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = 12 * time.Hour
|
||||
}
|
||||
if c.DefaultLeaseTTL <= 0 {
|
||||
c.DefaultLeaseTTL = time.Hour
|
||||
}
|
||||
if c.MaxLeaseTTL <= 0 {
|
||||
c.MaxLeaseTTL = 24 * time.Hour
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
c.SecureCookies = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
431
internal/platform/licensing.go
Normal file
431
internal/platform/licensing.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
type IssueInput struct {
|
||||
CustomerUserID string `json:"customerUserId,omitempty"`
|
||||
Customer string `json:"customer"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Days int `json:"days"`
|
||||
LeaseMinutes int `json:"leaseMinutes"`
|
||||
GraceHours int `json:"graceHours"`
|
||||
}
|
||||
|
||||
type LicenseView struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
Issuer string `json:"issuer"`
|
||||
Customer string `json:"customer"`
|
||||
CustomerUserID string `json:"customerUserId,omitempty"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Revoked bool `json:"revoked"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type validateRequest struct {
|
||||
Token string `json:"token"`
|
||||
Product string `json:"product"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Host string `json:"host,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
}
|
||||
|
||||
type validateResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) generateKeys(issuerKeyID, leaseKeyID string) error {
|
||||
issuerKeyID = strings.TrimSpace(issuerKeyID)
|
||||
leaseKeyID = strings.TrimSpace(leaseKeyID)
|
||||
if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) {
|
||||
return errors.New("key IDs must be 1-120 URL-safe characters")
|
||||
}
|
||||
issuerPub, issuerPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leasePub, leasePriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPriv))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePriv))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetKeysOnce(KeySet{
|
||||
IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPub), IssuerPrivateCipher: issuerCipher,
|
||||
LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePub), LeasePrivateCipher: leaseCipher,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) importKeys(issuerKeyID, issuerPrivateEncoded, leaseKeyID, leasePrivateEncoded string) error {
|
||||
issuerKeyID = strings.TrimSpace(issuerKeyID)
|
||||
leaseKeyID = strings.TrimSpace(leaseKeyID)
|
||||
if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) {
|
||||
return errors.New("key IDs must be 1-120 URL-safe characters")
|
||||
}
|
||||
issuerPrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(issuerPrivateEncoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer private key: %w", err)
|
||||
}
|
||||
leasePrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(leasePrivateEncoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("lease private key: %w", err)
|
||||
}
|
||||
issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPrivate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePrivate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetKeysOnce(KeySet{
|
||||
IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPrivate.Public().(ed25519.PublicKey)), IssuerPrivateCipher: issuerCipher,
|
||||
LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePrivate.Public().(ed25519.PublicKey)), LeasePrivateCipher: leaseCipher,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) trustStore() (licensekit.TrustStore, error) {
|
||||
keys, ok := s.store.KeySet()
|
||||
if !ok {
|
||||
return licensekit.TrustStore{}, errors.New("key pairs have not been initialized")
|
||||
}
|
||||
store := licensekit.NewTrustStore()
|
||||
store.LicenseKeys[keys.IssuerKeyID] = keys.IssuerPublicKey
|
||||
store.LeaseKeys[keys.LeaseKeyID] = keys.LeasePublicKey
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Server) issueLicense(actor User, input IssueInput) (LicenseRecord, string, error) {
|
||||
if actor.Role != RoleAdmin && actor.Role != RoleReseller {
|
||||
return LicenseRecord{}, "", errors.New("issuer role required")
|
||||
}
|
||||
keys, ok := s.store.KeySet()
|
||||
if !ok {
|
||||
return LicenseRecord{}, "", errors.New("key pairs have not been initialized")
|
||||
}
|
||||
input.Customer = strings.TrimSpace(input.Customer)
|
||||
input.Product = strings.TrimSpace(input.Product)
|
||||
input.Edition = strings.TrimSpace(input.Edition)
|
||||
if input.Customer == "" || input.Product == "" || input.Edition == "" {
|
||||
return LicenseRecord{}, "", errors.New("customer, product and edition are required")
|
||||
}
|
||||
if len(input.Customer) > 300 || len(input.Product) > 200 || len(input.Edition) > 100 {
|
||||
return LicenseRecord{}, "", errors.New("customer, product or edition is too long")
|
||||
}
|
||||
for _, value := range append(append([]string{}, input.Features...), append(input.Domains, input.InstanceIDs...)...) {
|
||||
if len(strings.TrimSpace(value)) > 300 {
|
||||
return LicenseRecord{}, "", errors.New("feature, domain or instance value is too long")
|
||||
}
|
||||
}
|
||||
mode, err := licensekit.ParseMode(input.Mode)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
if input.Days < 1 || input.Days > 3650 {
|
||||
return LicenseRecord{}, "", errors.New("days must be between 1 and 3650")
|
||||
}
|
||||
if input.LeaseMinutes <= 0 {
|
||||
input.LeaseMinutes = 60
|
||||
}
|
||||
if input.LeaseMinutes > 1440 || input.GraceHours < 0 || input.GraceHours > 8760 {
|
||||
return LicenseRecord{}, "", errors.New("lease or grace duration is outside the allowed range")
|
||||
}
|
||||
if input.CustomerUserID != "" {
|
||||
customerUser, ok := s.store.GetUser(input.CustomerUserID)
|
||||
if !ok || customerUser.Role != RoleCustomer || !customerUser.Active {
|
||||
return LicenseRecord{}, "", errors.New("customer portal account was not found")
|
||||
}
|
||||
if actor.Role == RoleReseller && customerUser.ParentUserID != actor.ID {
|
||||
return LicenseRecord{}, "", errors.New("customer portal account is not assigned to this reseller")
|
||||
}
|
||||
}
|
||||
privateEncoded, err := s.vault.Decrypt(keys.IssuerPrivateCipher)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
privateKey, err := licensekit.DecodePrivateKey(privateEncoded)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
licenseID := newID("lic")
|
||||
serverURL := ""
|
||||
if mode != licensekit.ModeOffline {
|
||||
serverURL = s.cfg.PublicURL
|
||||
}
|
||||
claims := licensekit.Claims{
|
||||
Version: 1, LicenseID: licenseID, Issuer: actor.DisplayName, Customer: input.Customer,
|
||||
Product: input.Product, Edition: input.Edition, Features: unique(input.Features), Limits: input.Limits,
|
||||
Domains: unique(input.Domains), InstanceIDs: unique(input.InstanceIDs), IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(time.Duration(input.Days) * 24 * time.Hour).Unix(),
|
||||
Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: int64(time.Duration(input.LeaseMinutes) * time.Minute / time.Second), OfflineGraceSeconds: int64(time.Duration(input.GraceHours) * time.Hour / time.Second), ServerURL: serverURL},
|
||||
Metadata: map[string]string{"issuedByUserId": actor.ID},
|
||||
}
|
||||
token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, claims)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(token)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
record := LicenseRecord{
|
||||
LicenseID: licenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token), Issuer: claims.Issuer,
|
||||
Customer: claims.Customer, CustomerUserID: input.CustomerUserID, IssuedByUserID: actor.ID,
|
||||
Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits,
|
||||
Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(mode), ServerURL: serverURL, ExpiresAt: claims.ExpiresAt,
|
||||
}
|
||||
if err := s.store.PutLicense(record); err != nil {
|
||||
return LicenseRecord{}, "", err
|
||||
}
|
||||
stored, _ := s.store.GetLicense(licenseID)
|
||||
return stored, token, nil
|
||||
}
|
||||
|
||||
func (s *Server) importLicense(actor User, token, customerUserID string) (LicenseRecord, error) {
|
||||
if actor.Role != RoleAdmin && actor.Role != RoleReseller {
|
||||
return LicenseRecord{}, errors.New("issuer role required")
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return LicenseRecord{}, errors.New("license token is required")
|
||||
}
|
||||
trust, err := s.trustStore()
|
||||
if err != nil {
|
||||
return LicenseRecord{}, err
|
||||
}
|
||||
verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC())
|
||||
if err != nil {
|
||||
return LicenseRecord{}, err
|
||||
}
|
||||
claims := verified.Claims
|
||||
if existing, ok := s.store.GetLicense(claims.LicenseID); ok {
|
||||
if existing.TokenHash == licensekit.TokenHash(token) {
|
||||
return existing, nil
|
||||
}
|
||||
return LicenseRecord{}, errors.New("license ID already exists with another token")
|
||||
}
|
||||
if customerUserID != "" {
|
||||
customer, ok := s.store.GetUser(customerUserID)
|
||||
if !ok || customer.Role != RoleCustomer || !customer.Active {
|
||||
return LicenseRecord{}, errors.New("customer portal account was not found")
|
||||
}
|
||||
if actor.Role == RoleReseller && customer.ParentUserID != actor.ID {
|
||||
return LicenseRecord{}, errors.New("customer portal account is not assigned to this reseller")
|
||||
}
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(token)
|
||||
if err != nil {
|
||||
return LicenseRecord{}, err
|
||||
}
|
||||
record := LicenseRecord{
|
||||
LicenseID: claims.LicenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token),
|
||||
Issuer: claims.Issuer, Customer: claims.Customer, CustomerUserID: customerUserID, IssuedByUserID: actor.ID,
|
||||
Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits,
|
||||
Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(claims.Verification.Mode),
|
||||
ServerURL: strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"), ExpiresAt: claims.ExpiresAt,
|
||||
}
|
||||
if err := s.store.PutLicense(record); err != nil {
|
||||
return LicenseRecord{}, err
|
||||
}
|
||||
stored, _ := s.store.GetLicense(record.LicenseID)
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (s *Server) tokenFor(record LicenseRecord) (string, error) {
|
||||
return s.vault.Decrypt(record.TokenCipher)
|
||||
}
|
||||
|
||||
func (s *Server) validateLicense(request validateRequest) (validateResponse, error) {
|
||||
store, err := s.trustStore()
|
||||
if err != nil {
|
||||
return validateResponse{Reason: err.Error()}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
verified, err := licensekit.VerifyLicense(store, request.Token, now)
|
||||
if err != nil {
|
||||
return validateResponse{Reason: err.Error()}, err
|
||||
}
|
||||
claims := verified.Claims
|
||||
if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil {
|
||||
return validateResponse{Reason: err.Error()}, err
|
||||
}
|
||||
record, ok := s.store.GetLicense(claims.LicenseID)
|
||||
if !ok {
|
||||
err := errors.New("license is not registered")
|
||||
return validateResponse{Reason: err.Error()}, err
|
||||
}
|
||||
if record.TokenHash != licensekit.TokenHash(request.Token) {
|
||||
err := errors.New("registered token does not match")
|
||||
return validateResponse{Reason: err.Error()}, err
|
||||
}
|
||||
if record.Revoked {
|
||||
reason := "license is revoked"
|
||||
if record.RevocationReason != "" {
|
||||
reason += ": " + record.RevocationReason
|
||||
}
|
||||
return validateResponse{Reason: reason}, errors.New(reason)
|
||||
}
|
||||
keys, _ := s.store.KeySet()
|
||||
leaseEncoded, err := s.vault.Decrypt(keys.LeasePrivateCipher)
|
||||
if err != nil {
|
||||
return validateResponse{Reason: "lease signing unavailable"}, err
|
||||
}
|
||||
leaseKey, err := licensekit.DecodePrivateKey(leaseEncoded)
|
||||
if err != nil {
|
||||
return validateResponse{Reason: "lease signing unavailable"}, err
|
||||
}
|
||||
ttl := s.cfg.DefaultLeaseTTL
|
||||
if claims.Verification.LeaseTTLSeconds > 0 {
|
||||
ttl = time.Duration(claims.Verification.LeaseTTLSeconds) * time.Second
|
||||
}
|
||||
if ttl > s.cfg.MaxLeaseTTL {
|
||||
ttl = s.cfg.MaxLeaseTTL
|
||||
}
|
||||
if remaining := time.Until(time.Unix(claims.ExpiresAt, 0)); ttl > remaining {
|
||||
ttl = remaining
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return validateResponse{Reason: "license has expired"}, errors.New("license has expired")
|
||||
}
|
||||
host, _ := licensekit.HostFromBaseURL(request.BaseURL)
|
||||
leaseClaims := licensekit.LeaseClaims{Version: 1, LeaseID: newID("lease"), LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: host, InstanceID: request.InstanceID, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix()}
|
||||
lease, err := licensekit.SignLease(leaseKey, keys.LeaseKeyID, leaseClaims)
|
||||
if err != nil {
|
||||
return validateResponse{Reason: "lease signing failed"}, err
|
||||
}
|
||||
return validateResponse{Valid: true, LeaseToken: lease, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func licenseView(record LicenseRecord, token string) LicenseView {
|
||||
return LicenseView{LicenseID: record.LicenseID, Issuer: record.Issuer, Customer: record.Customer, CustomerUserID: record.CustomerUserID, Product: record.Product, Edition: record.Edition, Features: record.Features, Limits: record.Limits, Domains: record.Domains, InstanceIDs: record.InstanceIDs, Mode: record.Mode, ServerURL: record.ServerURL, ExpiresAt: record.ExpiresAt, Revoked: record.Revoked, Reason: record.RevocationReason, Token: token, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
|
||||
}
|
||||
|
||||
func parseIssueForm(values url.Values) (IssueInput, error) {
|
||||
days, err := parseBoundedInt(values.Get("days"), 1, 3650)
|
||||
if err != nil {
|
||||
return IssueInput{}, fmt.Errorf("days: %w", err)
|
||||
}
|
||||
leaseMinutes, err := parseBoundedInt(values.Get("leaseMinutes"), 1, 1440)
|
||||
if err != nil {
|
||||
return IssueInput{}, fmt.Errorf("lease minutes: %w", err)
|
||||
}
|
||||
graceHours, err := parseBoundedInt(values.Get("graceHours"), 0, 8760)
|
||||
if err != nil {
|
||||
return IssueInput{}, fmt.Errorf("grace hours: %w", err)
|
||||
}
|
||||
limits, err := parseLimits(values.Get("limits"))
|
||||
if err != nil {
|
||||
return IssueInput{}, err
|
||||
}
|
||||
return IssueInput{CustomerUserID: strings.TrimSpace(values.Get("customerUserId")), Customer: values.Get("customer"), Product: values.Get("product"), Edition: values.Get("edition"), Features: csv(values.Get("features")), Limits: limits, Domains: csv(values.Get("domains")), InstanceIDs: csv(values.Get("instances")), Mode: values.Get("mode"), Days: days, LeaseMinutes: leaseMinutes, GraceHours: graceHours}, nil
|
||||
}
|
||||
|
||||
func parseBoundedInt(value string, min, max int) (int, error) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || n < min || n > max {
|
||||
return 0, fmt.Errorf("must be between %d and %d", min, max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseLimits(value string) (map[string]int64, error) {
|
||||
limits := map[string]int64{}
|
||||
for _, line := range strings.Split(value, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
|
||||
return nil, fmt.Errorf("invalid limit %q; expected name=value", line)
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return nil, fmt.Errorf("invalid limit %q", line)
|
||||
}
|
||||
limits[strings.TrimSpace(parts[0])] = value
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func csv(value string) []string { return unique(strings.Split(value, ",")) }
|
||||
|
||||
func unique(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
raw, err := randomToken(12)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UTC().UnixNano())
|
||||
}
|
||||
return prefix + "_" + raw
|
||||
}
|
||||
|
||||
func trustStoreJSON(store licensekit.TrustStore) string {
|
||||
data, _ := json.MarshalIndent(store, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func validKeyID(value string) bool {
|
||||
if len(value) < 1 || len(value) > 120 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == ':' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
85
internal/platform/models.go
Normal file
85
internal/platform/models.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleReseller Role = "reseller"
|
||||
RoleCustomer Role = "customer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role Role `json:"role"`
|
||||
ParentUserID string `json:"parentUserId,omitempty"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type KeySet struct {
|
||||
IssuerKeyID string `json:"issuerKeyId"`
|
||||
IssuerPublicKey string `json:"issuerPublicKey"`
|
||||
IssuerPrivateCipher string `json:"issuerPrivateCipher"`
|
||||
LeaseKeyID string `json:"leaseKeyId"`
|
||||
LeasePublicKey string `json:"leasePublicKey"`
|
||||
LeasePrivateCipher string `json:"leasePrivateCipher"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LicenseRecord struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
TokenCipher string `json:"tokenCipher"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
Issuer string `json:"issuer"`
|
||||
Customer string `json:"customer"`
|
||||
CustomerUserID string `json:"customerUserId,omitempty"`
|
||||
IssuedByUserID string `json:"issuedByUserId"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Revoked bool `json:"revoked"`
|
||||
RevocationReason string `json:"revocationReason,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
IDHash string `json:"idHash"`
|
||||
UserID string `json:"userId"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actorId,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type document struct {
|
||||
Version int `json:"version"`
|
||||
Keys *KeySet `json:"keys,omitempty"`
|
||||
Users map[string]User `json:"users"`
|
||||
Licenses map[string]LicenseRecord `json:"licenses"`
|
||||
Sessions map[string]Session `json:"sessions"`
|
||||
Audit []AuditEvent `json:"audit"`
|
||||
}
|
||||
|
||||
func unixNow() int64 { return time.Now().UTC().Unix() }
|
||||
205
internal/platform/platform_test.go
Normal file
205
internal/platform/platform_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
func testServer(t *testing.T, store *Store) *Server {
|
||||
t.Helper()
|
||||
master := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||
server, err := New(Config{Brand: "Test Platform", PublicURL: "https://licenses.example.test", MasterKey: master, BootstrapUsername: "admin", BootstrapPassword: "a-very-long-test-password", AdminAPIToken: "api-secret-0123456789-0123456789-ab", SessionTTL: time.Hour}, store, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
hash, err := HashPassword("a-very-long-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(hash, "a-very-long-password") || VerifyPassword(hash, "wrong-password") {
|
||||
t.Fatal("password verification mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysAreWriteOnceAndEncrypted(t *testing.T) {
|
||||
path := t.TempDir() + "/platform.json"
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := testServer(t, store)
|
||||
if err := server.generateKeys("issuer", "lease"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := server.generateKeys("issuer-2", "lease-2"); err != ErrKeysLocked {
|
||||
t.Fatalf("expected ErrKeysLocked, got %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "private") && strings.Contains(string(data), "MC4CAQ") {
|
||||
t.Fatal("data store appears to contain a plaintext private key")
|
||||
}
|
||||
keys, ok := store.KeySet()
|
||||
if !ok || !strings.HasPrefix(keys.IssuerPrivateCipher, "v1.") || !strings.HasPrefix(keys.LeasePrivateCipher, "v1.") {
|
||||
t.Fatal("private keys are not encrypted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueValidateAndRevoke(t *testing.T) {
|
||||
store, _ := OpenStore("")
|
||||
server := testServer(t, store)
|
||||
if err := server.generateKeys("issuer", "lease"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, ok := store.FindUserByUsername("admin")
|
||||
if !ok {
|
||||
t.Fatal("bootstrap admin missing")
|
||||
}
|
||||
record, token, err := server.issueLicense(admin, IssueInput{Customer: "ACME", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 72})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trust, _ := server.trustStore()
|
||||
verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.Claims.Verification.ServerURL != "https://licenses.example.test" {
|
||||
t.Fatalf("unexpected embedded server URL %q", verified.Claims.Verification.ServerURL)
|
||||
}
|
||||
response, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"})
|
||||
if err != nil || !response.Valid || response.LeaseToken == "" {
|
||||
t.Fatalf("validation failed: %#v %v", response, err)
|
||||
}
|
||||
if err := store.SetRevoked(record.LicenseID, true, "payment"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"}); err == nil {
|
||||
t.Fatal("revoked license was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementAndValidationAPI(t *testing.T) {
|
||||
store, _ := OpenStore("")
|
||||
server := testServer(t, store)
|
||||
if err := server.generateKeys("issuer", "lease"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(server.Handler())
|
||||
defer ts.Close()
|
||||
issue := IssueInput{Customer: "API Customer", Product: "api-product", Edition: "team", Domains: []string{"*"}, Mode: "hybrid", Days: 10, LeaseMinutes: 30, GraceHours: 24}
|
||||
body, _ := json.Marshal(issue)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/api/v1/licenses", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created LicenseView
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated || created.Token == "" {
|
||||
t.Fatalf("issue API failed: %d %#v", resp.StatusCode, created)
|
||||
}
|
||||
validation, _ := json.Marshal(validateRequest{Token: created.Token, Product: "api-product", BaseURL: "https://customer.example.test"})
|
||||
resp, err = http.Post(ts.URL+"/api/v1/licenses/validate", "application/json", bytes.NewReader(validation))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("validation API failed: %d %s", resp.StatusCode, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCustomerIsolation(t *testing.T) {
|
||||
store, _ := OpenStore("")
|
||||
server := testServer(t, store)
|
||||
if err := server.generateKeys("issuer", "lease"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := HashPassword("a-very-long-customer-password")
|
||||
resellerA := User{ID: "reseller_a", Username: "reseller.a", DisplayName: "Reseller A", Role: RoleReseller, PasswordHash: hash, Active: true}
|
||||
resellerB := User{ID: "reseller_b", Username: "reseller.b", DisplayName: "Reseller B", Role: RoleReseller, PasswordHash: hash, Active: true}
|
||||
customer := User{ID: "customer_a", Username: "customer.a", DisplayName: "Customer A", Role: RoleCustomer, ParentUserID: resellerA.ID, PasswordHash: hash, Active: true}
|
||||
for _, user := range []User{resellerA, resellerB, customer} {
|
||||
if err := store.CreateUser(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
input := IssueInput{CustomerUserID: customer.ID, Customer: customer.DisplayName, Product: "product", Edition: "pro", Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 24}
|
||||
if _, _, err := server.issueLicense(resellerB, input); err == nil {
|
||||
t.Fatal("unassigned reseller issued a license for another reseller's customer")
|
||||
}
|
||||
record, _, err := server.issueLicense(resellerA, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := store.ListLicensesFor(resellerB); len(got) != 0 {
|
||||
t.Fatalf("reseller B can see %d foreign licenses", len(got))
|
||||
}
|
||||
if got := store.ListLicensesFor(customer); len(got) != 1 || got[0].LicenseID != record.LicenseID {
|
||||
t.Fatalf("customer portal did not receive its license: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRegistrationCompatibility(t *testing.T) {
|
||||
store, _ := OpenStore("")
|
||||
server := testServer(t, store)
|
||||
if err := server.generateKeys("issuer", "lease"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, _ := store.KeySet()
|
||||
encoded, err := server.vault.Decrypt(keys.IssuerPrivateCipher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey, err := licensekit.DecodePrivateKey(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, licensekit.Claims{Version: 1, LicenseID: "legacy-license", Issuer: "legacy", Customer: "Legacy Customer", Product: "legacy-product", Edition: "pro", Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, ServerURL: "https://licenses.example.test"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(server.Handler())
|
||||
defer ts.Close()
|
||||
body, _ := json.Marshal(map[string]string{"token": token})
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/admin/licenses", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("legacy register status %d", resp.StatusCode)
|
||||
}
|
||||
if _, ok := store.GetLicense("legacy-license"); !ok {
|
||||
t.Fatal("legacy license was not imported")
|
||||
}
|
||||
}
|
||||
45
internal/platform/ratelimit.go
Normal file
45
internal/platform/ratelimit.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type attempt struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]attempt
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newLimiter(limit int, window time.Duration) *limiter {
|
||||
return &limiter{entries: map[string]attempt{}, limit: limit, window: window}
|
||||
}
|
||||
|
||||
func (l *limiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
entry := l.entries[key]
|
||||
if entry.reset.IsZero() || now.After(entry.reset) {
|
||||
l.entries[key] = attempt{count: 1, reset: now.Add(l.window)}
|
||||
return true
|
||||
}
|
||||
if entry.count >= l.limit {
|
||||
return false
|
||||
}
|
||||
entry.count++
|
||||
l.entries[key] = entry
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *limiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
}
|
||||
157
internal/platform/security.go
Normal file
157
internal/platform/security.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const passwordIterations = 210_000
|
||||
|
||||
type Vault struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewVault(encoded string) (*Vault, error) {
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if encoded == "" {
|
||||
return nil, errors.New("LICENSE_MASTER_KEY is required")
|
||||
}
|
||||
key, err := base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
key, err = base64.StdEncoding.DecodeString(encoded)
|
||||
}
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("LICENSE_MASTER_KEY must be a base64-encoded 32-byte key")
|
||||
}
|
||||
return &Vault{key: key}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
payload := append(nonce, sealed...)
|
||||
return "v1." + base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func (v *Vault) Decrypt(ciphertext string) (string, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(ciphertext), ".", 2)
|
||||
if len(parts) != 2 || parts[0] != "v1" {
|
||||
return "", errors.New("unsupported encrypted value")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload) < gcm.NonceSize() {
|
||||
return "", errors.New("encrypted value is truncated")
|
||||
}
|
||||
plain, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("encrypted value authentication failed")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 12 {
|
||||
return "", errors.New("password must contain at least 12 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return "", errors.New("password is too long")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dk := pbkdf2SHA256([]byte(password), salt, passwordIterations, 32)
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(dk)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iterations, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iterations < 100_000 || iterations > 2_000_000 {
|
||||
return false
|
||||
}
|
||||
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
expected, err2 := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err1 != nil || err2 != nil || len(expected) == 0 {
|
||||
return false
|
||||
}
|
||||
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
hLen := sha256.Size
|
||||
blocks := (keyLen + hLen - 1) / hLen
|
||||
out := make([]byte, 0, blocks*hLen)
|
||||
for i := 1; i <= blocks; i++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
mac.Write([]byte{byte(i >> 24), byte(i >> 16), byte(i >> 8), byte(i)})
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for j := 1; j < iterations; j++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for k := range t {
|
||||
t[k] ^= u[k]
|
||||
}
|
||||
}
|
||||
out = append(out, t...)
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
|
||||
func randomToken(bytes int) (string, error) {
|
||||
buf := make([]byte, bytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func tokenHash(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func constantTokenEqual(expected, actual string) bool {
|
||||
if expected == "" || actual == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(tokenHash(expected)), []byte(tokenHash(actual))) == 1
|
||||
}
|
||||
705
internal/platform/server.go
Normal file
705
internal/platform/server.go
Normal file
@@ -0,0 +1,705 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
webassets "github.com/b1tsblog/license-platform/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
vault *Vault
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
templates *template.Template
|
||||
loginLimiter *limiter
|
||||
apiLimiter *limiter
|
||||
}
|
||||
|
||||
type loginData struct {
|
||||
Brand string
|
||||
CSRF string
|
||||
Error string
|
||||
}
|
||||
|
||||
type portalData struct {
|
||||
Brand, PublicURL, PortalTitle, RoleLabel, Headline, Subline string
|
||||
User User
|
||||
CSRF, Message, Error string
|
||||
KeysReady bool
|
||||
Keys *KeySet
|
||||
TrustStoreJSON string
|
||||
Users, Customers, Resellers []User
|
||||
Licenses []LicenseRecord
|
||||
Audit []AuditEvent
|
||||
ActiveCount, ExpiringCount, RevokedCount int
|
||||
}
|
||||
|
||||
type tokenData struct {
|
||||
Brand string
|
||||
License LicenseRecord
|
||||
Token string
|
||||
}
|
||||
|
||||
func New(cfg Config, store *Store, logger *slog.Logger) (*Server, error) {
|
||||
if err := cfg.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store == nil {
|
||||
return nil, errors.New("store is required")
|
||||
}
|
||||
vault, err := NewVault(cfg.MasterKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
funcs := template.FuncMap{
|
||||
"formatTime": func(value int64) string {
|
||||
if value <= 0 {
|
||||
return "–"
|
||||
}
|
||||
return time.Unix(value, 0).UTC().Format("02.01.2006 · 15:04 UTC")
|
||||
},
|
||||
"initial": func(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "?"
|
||||
}
|
||||
return strings.ToUpper(string([]rune(value)[0]))
|
||||
},
|
||||
}
|
||||
templates, err := template.New("portal").Funcs(funcs).ParseFS(webassets.FS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, store: store, vault: vault, logger: logger, mux: http.NewServeMux(), templates: templates, loginLimiter: newLimiter(8, 15*time.Minute), apiLimiter: newLimiter(180, time.Minute)}
|
||||
if err := s.bootstrap(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.securityHeaders(s.requestSizeLimit(s.requestLog(s.mux)))
|
||||
}
|
||||
|
||||
func (s *Server) bootstrap() error {
|
||||
users := s.store.ListUsers()
|
||||
if len(users) > 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.BootstrapUsername) == "" || strings.TrimSpace(s.cfg.BootstrapPassword) == "" {
|
||||
return errors.New("bootstrap admin credentials are required for an empty data store")
|
||||
}
|
||||
hash, err := HashPassword(s.cfg.BootstrapPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap password: %w", err)
|
||||
}
|
||||
created, err := s.store.EnsureBootstrapAdmin(s.cfg.BootstrapUsername, s.cfg.BootstrapName, hash)
|
||||
if err == nil && created {
|
||||
s.logger.Warn("bootstrap administrator created", "username", normalizeUsername(s.cfg.BootstrapUsername))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
staticFS, _ := fs.Sub(webassets.FS, "static")
|
||||
s.mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS))))
|
||||
s.mux.HandleFunc("GET /healthz", s.health)
|
||||
s.mux.HandleFunc("GET /.well-known/license-server", s.discovery)
|
||||
s.mux.HandleFunc("GET /api/v1/trust-store", s.publicTrustStore)
|
||||
s.mux.HandleFunc("GET /login", s.loginPage)
|
||||
s.mux.HandleFunc("POST /login", s.login)
|
||||
s.mux.HandleFunc("POST /logout", s.logout)
|
||||
s.mux.HandleFunc("POST /account/password", s.changePassword)
|
||||
s.mux.HandleFunc("GET /", s.portal)
|
||||
s.mux.HandleFunc("POST /admin/keys/generate", s.generateKeysForm)
|
||||
s.mux.HandleFunc("POST /admin/keys/import", s.importKeysForm)
|
||||
s.mux.HandleFunc("POST /admin/users", s.createUserForm)
|
||||
s.mux.HandleFunc("POST /reseller/customers", s.createCustomerForm)
|
||||
s.mux.HandleFunc("POST /licenses", s.issueForm)
|
||||
s.mux.HandleFunc("GET /licenses/{id}/token", s.tokenPage)
|
||||
s.mux.HandleFunc("POST /licenses/{id}/revoke", s.revokeForm)
|
||||
s.mux.HandleFunc("POST /licenses/{id}/restore", s.restoreForm)
|
||||
|
||||
s.mux.HandleFunc("POST /api/v1/licenses/validate", s.validateAPI)
|
||||
s.mux.HandleFunc("POST /v1/introspect", s.validateAPI)
|
||||
s.mux.HandleFunc("GET /api/v1/licenses", s.listAPI)
|
||||
s.mux.HandleFunc("POST /api/v1/licenses", s.issueAPI)
|
||||
s.mux.HandleFunc("POST /api/v1/licenses/import", s.importAPI)
|
||||
s.mux.HandleFunc("POST /api/v1/licenses/{id}/revoke", s.revokeAPI)
|
||||
s.mux.HandleFunc("POST /api/v1/licenses/{id}/restore", s.restoreAPI)
|
||||
|
||||
// Compatibility with the original reference server and licenseweb.
|
||||
s.mux.HandleFunc("GET /v1/admin/licenses", s.listAPI)
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses", s.legacyRegisterAPI)
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.revokeAPI)
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.restoreAPI)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
_, ready := s.store.KeySet()
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "keysReady": ready, "version": "1.0.0"})
|
||||
}
|
||||
|
||||
func (s *Server) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"issuer": s.cfg.Brand, "serverUrl": s.cfg.PublicURL, "validationEndpoint": s.cfg.PublicURL + "/api/v1/licenses/validate", "trustStoreEndpoint": s.cfg.PublicURL + "/api/v1/trust-store"})
|
||||
}
|
||||
|
||||
func (s *Server) publicTrustStore(w http.ResponseWriter, _ *http.Request) {
|
||||
store, err := s.trustStore()
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, store)
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := s.currentUser(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "login.html", loginData{Brand: s.cfg.Brand, CSRF: s.loginCSRF(w, r), Error: r.URL.Query().Get("error")})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.Allow(remoteIP(r)) {
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkLoginCSRF(r) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := normalizeUsername(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
if len(username) > 200 || len(password) > 1024 {
|
||||
http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
user, ok := s.store.FindUserByUsername(username)
|
||||
if !ok || !user.Active || !VerifyPassword(user.PasswordHash, password) {
|
||||
_ = s.audit(User{}, "login.failed", username, "invalid credentials", r)
|
||||
http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
raw, session, err := s.newSession(user)
|
||||
if err != nil {
|
||||
http.Error(w, "session creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.loginLimiter.Reset(remoteIP(r))
|
||||
s.setSessionCookie(w, raw, time.Unix(session.ExpiresAt, 0))
|
||||
_ = s.audit(user, "login.succeeded", user.ID, "portal session created", r)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteSession(session.IDHash)
|
||||
s.clearSessionCookie(w)
|
||||
_ = s.audit(user, "logout", user.ID, "session closed", r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !VerifyPassword(user.PasswordHash, r.FormValue("currentPassword")) {
|
||||
_ = s.audit(user, "password.change.failed", user.ID, "current password mismatch", r)
|
||||
http.Redirect(w, r, "/?error=Aktuelles+Passwort+ist+falsch", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(r.FormValue("newPassword"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdatePassword(user.ID, hash); err != nil {
|
||||
http.Error(w, "password update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteSessionsForUser(user.ID)
|
||||
s.clearSessionCookie(w)
|
||||
_ = s.audit(user, "password.changed", user.ID, "all sessions invalidated", r)
|
||||
http.Redirect(w, r, "/login?error=Passwort+geändert.+Bitte+neu+anmelden", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) portal(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
licenses := s.store.ListLicensesFor(user)
|
||||
keys, keysReady := s.store.KeySet()
|
||||
data := portalData{Brand: s.cfg.Brand, PublicURL: s.cfg.PublicURL, User: user, CSRF: session.CSRFToken, KeysReady: keysReady, Keys: keys, Licenses: licenses, Message: r.URL.Query().Get("message"), Error: r.URL.Query().Get("error")}
|
||||
data.PortalTitle, data.RoleLabel, data.Headline, data.Subline = roleCopy(user.Role)
|
||||
for _, record := range licenses {
|
||||
if record.Revoked {
|
||||
data.RevokedCount++
|
||||
} else {
|
||||
data.ActiveCount++
|
||||
}
|
||||
if !record.Revoked && record.ExpiresAt <= time.Now().UTC().Add(30*24*time.Hour).Unix() {
|
||||
data.ExpiringCount++
|
||||
}
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
data.Users = s.store.ListUsers()
|
||||
data.Audit = s.store.ListAudit(50)
|
||||
}
|
||||
for _, candidate := range s.store.ListUsers() {
|
||||
if candidate.Role == RoleReseller && candidate.Active && user.Role == RoleAdmin {
|
||||
data.Resellers = append(data.Resellers, candidate)
|
||||
}
|
||||
if candidate.Role == RoleCustomer && candidate.Active {
|
||||
if user.Role == RoleAdmin || (user.Role == RoleReseller && candidate.ParentUserID == user.ID) {
|
||||
data.Customers = append(data.Customers, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
if keysReady {
|
||||
if trust, err := s.trustStore(); err == nil {
|
||||
data.TrustStoreJSON = trustStoreJSON(trust)
|
||||
}
|
||||
}
|
||||
s.render(w, "portal.html", data)
|
||||
}
|
||||
|
||||
func roleCopy(role Role) (string, string, string, string) {
|
||||
switch role {
|
||||
case RoleAdmin:
|
||||
return "Admin / Management", "Management", "Steuere Vertrauen, Zugänge und Lizenzbestand.", "Zentrale Kontrolle über Signaturschlüssel, Nutzer, Aussteller und den vollständigen Audit-Trail."
|
||||
case RoleReseller:
|
||||
return "Reseller / Autor", "Reseller", "Lizenzen ausstellen, ohne die Root Keys zu sehen.", "Erstelle und verwalte ausschließlich die von dir verantworteten Kundenlizenzen."
|
||||
default:
|
||||
return "Kunde / Client", "Kunde", "Deine Lizenzen. Transparent und jederzeit verfügbar.", "Prüfe Laufzeit, Status, Features und sichere Client-Konfigurationen für deine Produkte."
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) generateKeysForm(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.requireRole(w, r, RoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err := s.generateKeys(r.FormValue("issuerKeyId"), r.FormValue("leaseKeyId"))
|
||||
if err != nil {
|
||||
_ = s.audit(user, "keys.generate.denied", "keyset", err.Error(), r)
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.audit(user, "keys.generated", "keyset", "issuer and lease key pairs initialized", r)
|
||||
http.Redirect(w, r, "/?message=Schlüsselpaare+wurden+erzeugt+und+gesperrt", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) importKeysForm(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.requireRole(w, r, RoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err := s.importKeys(r.FormValue("issuerKeyId"), r.FormValue("issuerPrivate"), r.FormValue("leaseKeyId"), r.FormValue("leasePrivate"))
|
||||
if err != nil {
|
||||
_ = s.audit(user, "keys.import.denied", "keyset", err.Error(), r)
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.audit(user, "keys.imported", "keyset", "existing issuer and lease keys imported and locked", r)
|
||||
http.Redirect(w, r, "/?message=Bestehende+Schlüsselpaare+verschlüsselt+importiert+und+gesperrt", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) createUserForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, session, ok := s.requireRole(w, r, RoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
role := Role(strings.TrimSpace(r.FormValue("role")))
|
||||
if role != RoleAdmin && role != RoleReseller && role != RoleCustomer {
|
||||
http.Redirect(w, r, "/?error=Ungültige+Rolle", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
parentUserID := ""
|
||||
if role == RoleCustomer {
|
||||
parentUserID = strings.TrimSpace(r.FormValue("resellerUserId"))
|
||||
if parentUserID != "" {
|
||||
parent, ok := s.store.GetUser(parentUserID)
|
||||
if !ok || parent.Role != RoleReseller || !parent.Active {
|
||||
http.Redirect(w, r, "/?error=Zugeordneter+Reseller+ist+ungültig", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: role, ParentUserID: parentUserID, PasswordHash: hash, Active: true}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Username
|
||||
}
|
||||
if err := s.store.CreateUser(user); err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "user.created", user.ID, string(role)+":"+user.Username, r)
|
||||
http.Redirect(w, r, "/?message=Benutzer+angelegt", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) createCustomerForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, session, ok := s.requireRole(w, r, RoleReseller)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: RoleCustomer, ParentUserID: actor.ID, PasswordHash: hash, Active: true}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Username
|
||||
}
|
||||
if err := s.store.CreateUser(user); err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "customer.created", user.ID, user.Username, r)
|
||||
http.Redirect(w, r, "/?message=Kundenportal+angelegt", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) issueForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
input, err := parseIssueForm(r.Form)
|
||||
if err == nil {
|
||||
_, _, err = s.issueLicense(actor, input)
|
||||
}
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "license.issued", input.Product, input.Customer+" / "+input.Edition, r)
|
||||
http.Redirect(w, r, "/?message=Lizenz+signiert+und+registriert", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) tokenPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, ok := s.store.GetLicense(r.PathValue("id"))
|
||||
if !ok || !canAccessLicense(user, record) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
token, err := s.tokenFor(record)
|
||||
if err != nil {
|
||||
http.Error(w, "token unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "token.html", tokenData{Brand: s.cfg.Brand, License: record, Token: token})
|
||||
}
|
||||
|
||||
func (s *Server) revokeForm(w http.ResponseWriter, r *http.Request) { s.mutateLicenseForm(w, r, true) }
|
||||
func (s *Server) restoreForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.mutateLicenseForm(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) mutateLicenseForm(w http.ResponseWriter, r *http.Request, revoked bool) {
|
||||
actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.verifyCSRF(r, session) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
record, ok := s.store.GetLicense(r.PathValue("id"))
|
||||
if !ok || !canManageLicense(actor, record) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
reason := "manually revoked"
|
||||
if !revoked {
|
||||
reason = ""
|
||||
}
|
||||
if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
action := "license.restored"
|
||||
if revoked {
|
||||
action = "license.revoked"
|
||||
}
|
||||
_ = s.audit(actor, action, record.LicenseID, reason, r)
|
||||
http.Redirect(w, r, "/?message=Lizenzstatus+aktualisiert", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) validateAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.apiLimiter.Allow(remoteIP(r)) {
|
||||
s.writeJSON(w, http.StatusTooManyRequests, validateResponse{Reason: "rate limit exceeded"})
|
||||
return
|
||||
}
|
||||
var request validateRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, validateResponse{Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
response, err := s.validateLicense(request)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusForbidden, response)
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Server) listAPI(w http.ResponseWriter, r *http.Request) {
|
||||
actor, err := s.apiActor(r)
|
||||
if err != nil {
|
||||
s.apiUnauthorized(w)
|
||||
return
|
||||
}
|
||||
records := s.store.ListLicensesFor(actor)
|
||||
views := make([]LicenseView, 0, len(records))
|
||||
for _, record := range records {
|
||||
views = append(views, licenseView(record, ""))
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"licenses": views})
|
||||
}
|
||||
|
||||
func (s *Server) issueAPI(w http.ResponseWriter, r *http.Request) {
|
||||
actor, err := s.apiActor(r)
|
||||
if err != nil {
|
||||
s.apiUnauthorized(w)
|
||||
return
|
||||
}
|
||||
var input IssueInput
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, token, err := s.issueLicense(actor, input)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "license.issued.api", record.LicenseID, record.Product, r)
|
||||
s.writeJSON(w, http.StatusCreated, licenseView(record, token))
|
||||
}
|
||||
|
||||
func (s *Server) importAPI(w http.ResponseWriter, r *http.Request) {
|
||||
actor, err := s.apiActor(r)
|
||||
if err != nil {
|
||||
s.apiUnauthorized(w)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
CustomerUserID string `json:"customerUserId,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err := s.importLicense(actor, payload.Token, strings.TrimSpace(payload.CustomerUserID))
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "license.imported.api", record.LicenseID, record.Product, r)
|
||||
s.writeJSON(w, http.StatusCreated, licenseView(record, ""))
|
||||
}
|
||||
|
||||
func (s *Server) legacyRegisterAPI(w http.ResponseWriter, r *http.Request) {
|
||||
actor, err := s.apiActor(r)
|
||||
if err != nil {
|
||||
s.apiUnauthorized(w)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err := s.importLicense(actor, payload.Token, "")
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.audit(actor, "license.registered.legacy", record.LicenseID, record.Product, r)
|
||||
s.writeJSON(w, http.StatusCreated, licenseView(record, ""))
|
||||
}
|
||||
|
||||
func (s *Server) revokeAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, true) }
|
||||
func (s *Server) restoreAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, false) }
|
||||
|
||||
func (s *Server) mutateLicenseAPI(w http.ResponseWriter, r *http.Request, revoked bool) {
|
||||
actor, err := s.apiActor(r)
|
||||
if err != nil {
|
||||
s.apiUnauthorized(w)
|
||||
return
|
||||
}
|
||||
record, ok := s.store.GetLicense(r.PathValue("id"))
|
||||
if !ok || !canManageLicense(actor, record) {
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "license not found"})
|
||||
return
|
||||
}
|
||||
reason := ""
|
||||
if revoked {
|
||||
var payload struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = decodeJSONAllowEmpty(r, &payload)
|
||||
reason = strings.TrimSpace(payload.Reason)
|
||||
if reason == "" {
|
||||
reason = "revoked via API"
|
||||
}
|
||||
}
|
||||
if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil {
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := "license.restored.api"
|
||||
if revoked {
|
||||
action = "license.revoked.api"
|
||||
}
|
||||
_ = s.audit(actor, action, record.LicenseID, reason, r)
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"licenseId": record.LicenseID, "revoked": revoked})
|
||||
}
|
||||
|
||||
func canAccessLicense(user User, record LicenseRecord) bool {
|
||||
return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID) || (user.Role == RoleCustomer && record.CustomerUserID == user.ID)
|
||||
}
|
||||
|
||||
func canManageLicense(user User, record LicenseRecord) bool {
|
||||
return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID)
|
||||
}
|
||||
|
||||
func (s *Server) audit(actor User, action, target, detail string, r *http.Request) error {
|
||||
event := AuditEvent{ID: newID("evt"), ActorID: actor.ID, Action: action, Target: target, Detail: detail, RemoteIP: remoteIP(r), CreatedAt: unixNow()}
|
||||
return s.store.AddAudit(event)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Error("render template", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func (s *Server) apiUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, target any) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if dec.Decode(&struct{}{}) != io.EOF {
|
||||
return errors.New("request body must contain one JSON object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONAllowEmpty(r *http.Request, target any) error {
|
||||
err := decodeJSON(r, target)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func queryValue(value string) string { return url.QueryEscape(value) }
|
||||
|
||||
func (s *Server) requestSizeLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; img-src 'self' data:; script-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.cfg.SecureCookies {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requestLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "remote_ip", remoteIP(r), "duration_ms", time.Since(started).Milliseconds())
|
||||
})
|
||||
}
|
||||
353
internal/platform/store.go
Normal file
353
internal/platform/store.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrKeysLocked = errors.New("key pairs already exist and cannot be overwritten")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
doc document
|
||||
}
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, doc: document{Version: 1, Users: map[string]User{}, Licenses: map[string]LicenseRecord{}, Sessions: map[string]Session{}, Audit: []AuditEvent{}}}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return s, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.doc.Version != 1 {
|
||||
return nil, errors.New("unsupported data-store version")
|
||||
}
|
||||
if s.doc.Users == nil {
|
||||
s.doc.Users = map[string]User{}
|
||||
}
|
||||
if s.doc.Licenses == nil {
|
||||
s.doc.Licenses = map[string]LicenseRecord{}
|
||||
}
|
||||
if s.doc.Sessions == nil {
|
||||
s.doc.Sessions = map[string]Session{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) KeySet() (*KeySet, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.doc.Keys == nil {
|
||||
return nil, false
|
||||
}
|
||||
copy := *s.doc.Keys
|
||||
return ©, true
|
||||
}
|
||||
|
||||
func (s *Store) SetKeysOnce(keys KeySet) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.doc.Keys != nil {
|
||||
return ErrKeysLocked
|
||||
}
|
||||
keys.CreatedAt = unixNow()
|
||||
s.doc.Keys = &keys
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(user User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
username := normalizeUsername(user.Username)
|
||||
if !validUsername(username) {
|
||||
return errors.New("username must be 3-120 characters and contain only letters, numbers, dot, dash, underscore or @")
|
||||
}
|
||||
if len(strings.TrimSpace(user.DisplayName)) > 200 {
|
||||
return errors.New("display name is too long")
|
||||
}
|
||||
for _, existing := range s.doc.Users {
|
||||
if normalizeUsername(existing.Username) == username {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
now := unixNow()
|
||||
user.Username = username
|
||||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
if !user.Active {
|
||||
user.Active = true
|
||||
}
|
||||
s.doc.Users[user.ID] = user
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) EnsureBootstrapAdmin(username, displayName, passwordHash string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, user := range s.doc.Users {
|
||||
if user.Role == RoleAdmin {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
return false, errors.New("bootstrap username is invalid")
|
||||
}
|
||||
now := unixNow()
|
||||
user := User{ID: "usr_admin_bootstrap", Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdmin, PasswordHash: passwordHash, Active: true, CreatedAt: now, UpdatedAt: now}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = "Administrator"
|
||||
}
|
||||
s.doc.Users[user.ID] = user
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) FindUserByUsername(username string) (User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
username = normalizeUsername(username)
|
||||
for _, user := range s.doc.Users {
|
||||
if normalizeUsername(user.Username) == username {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(id string) (User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
user, ok := s.doc.Users[id]
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePassword(userID, passwordHash string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.doc.Users[userID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
user.PasswordHash = passwordHash
|
||||
user.UpdatedAt = unixNow()
|
||||
s.doc.Users[userID] = user
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers() []User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]User, 0, len(s.doc.Users))
|
||||
for _, user := range s.doc.Users {
|
||||
out = append(out, user)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Role == out[j].Role {
|
||||
return out[i].Username < out[j].Username
|
||||
}
|
||||
return out[i].Role < out[j].Role
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) PutLicense(record LicenseRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := unixNow()
|
||||
if existing, ok := s.doc.Licenses[record.LicenseID]; ok {
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if record.CreatedAt == 0 {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
s.doc.Licenses[record.LicenseID] = record
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) GetLicense(id string) (LicenseRecord, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
record, ok := s.doc.Licenses[id]
|
||||
return cloneLicense(record), ok
|
||||
}
|
||||
|
||||
func (s *Store) ListLicensesFor(user User) []LicenseRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]LicenseRecord, 0, len(s.doc.Licenses))
|
||||
for _, record := range s.doc.Licenses {
|
||||
switch user.Role {
|
||||
case RoleAdmin:
|
||||
out = append(out, cloneLicense(record))
|
||||
case RoleReseller:
|
||||
if record.IssuedByUserID == user.ID {
|
||||
out = append(out, cloneLicense(record))
|
||||
}
|
||||
case RoleCustomer:
|
||||
if record.CustomerUserID == user.ID {
|
||||
out = append(out, cloneLicense(record))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) SetRevoked(id string, revoked bool, reason string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.doc.Licenses[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
record.Revoked = revoked
|
||||
if revoked {
|
||||
record.RevocationReason = strings.TrimSpace(reason)
|
||||
} else {
|
||||
record.RevocationReason = ""
|
||||
}
|
||||
record.UpdatedAt = unixNow()
|
||||
s.doc.Licenses[id] = record
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(session Session) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupSessionsLocked(time.Now().UTC())
|
||||
s.doc.Sessions[session.IDHash] = session
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(idHash string, now time.Time) (Session, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.doc.Sessions[idHash]
|
||||
if !ok || session.ExpiresAt <= now.Unix() {
|
||||
return Session{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSessionsForUser(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, session := range s.doc.Sessions {
|
||||
if session.UserID == userID {
|
||||
delete(s.doc.Sessions, id)
|
||||
}
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(idHash string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.doc.Sessions, idHash)
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) AddAudit(event AuditEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if event.CreatedAt == 0 {
|
||||
event.CreatedAt = unixNow()
|
||||
}
|
||||
s.doc.Audit = append(s.doc.Audit, event)
|
||||
if len(s.doc.Audit) > 2000 {
|
||||
s.doc.Audit = append([]AuditEvent(nil), s.doc.Audit[len(s.doc.Audit)-2000:]...)
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) ListAudit(limit int) []AuditEvent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
start := len(s.doc.Audit) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
out := append([]AuditEvent(nil), s.doc.Audit[start:]...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) persistLocked() error {
|
||||
if strings.TrimSpace(s.path) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s.doc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := s.path + ".tmp"
|
||||
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, s.path)
|
||||
}
|
||||
|
||||
func (s *Store) cleanupSessionsLocked(now time.Time) {
|
||||
for key, session := range s.doc.Sessions {
|
||||
if session.ExpiresAt <= now.Unix() {
|
||||
delete(s.doc.Sessions, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUsername(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func cloneLicense(record LicenseRecord) LicenseRecord {
|
||||
record.Features = append([]string(nil), record.Features...)
|
||||
record.Domains = append([]string(nil), record.Domains...)
|
||||
record.InstanceIDs = append([]string(nil), record.InstanceIDs...)
|
||||
if record.Limits != nil {
|
||||
copy := make(map[string]int64, len(record.Limits))
|
||||
for key, value := range record.Limits {
|
||||
copy[key] = value
|
||||
}
|
||||
record.Limits = copy
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func validUsername(value string) bool {
|
||||
if len(value) < 3 || len(value) > 120 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == '@' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
252
openapi.yaml
Normal file
252
openapi.yaml
Normal file
@@ -0,0 +1,252 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Universal License Platform API
|
||||
version: 1.0.0
|
||||
description: Management and runtime validation API for signed product licenses.
|
||||
servers:
|
||||
- url: https://licenses.example.org
|
||||
paths:
|
||||
/.well-known/license-server:
|
||||
get:
|
||||
summary: Discover the license platform
|
||||
responses:
|
||||
'200':
|
||||
description: Discovery document
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Discovery'
|
||||
/api/v1/trust-store:
|
||||
get:
|
||||
summary: Read public issuer and lease verification keys
|
||||
responses:
|
||||
'200':
|
||||
description: Public Ed25519 trust store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TrustStore'
|
||||
'503':
|
||||
$ref: '#/components/responses/Error'
|
||||
/api/v1/licenses/validate:
|
||||
post:
|
||||
summary: Validate a license and issue a short-lived signed lease
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Valid license and signed lease
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/Error'
|
||||
'403':
|
||||
$ref: '#/components/responses/Error'
|
||||
'429':
|
||||
$ref: '#/components/responses/Error'
|
||||
/api/v1/licenses:
|
||||
get:
|
||||
security: [{bearerAuth: []}]
|
||||
summary: List licenses visible to the management actor
|
||||
responses:
|
||||
'200':
|
||||
description: License list without plaintext tokens
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [licenses]
|
||||
properties:
|
||||
licenses:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/License'}
|
||||
'401': {$ref: '#/components/responses/Error'}
|
||||
post:
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Sign and register a license
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueLicense'
|
||||
responses:
|
||||
'201':
|
||||
description: Created license; plaintext token is returned only here
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/License'
|
||||
'400': {$ref: '#/components/responses/Error'}
|
||||
'401': {$ref: '#/components/responses/Error'}
|
||||
/api/v1/licenses/import:
|
||||
post:
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Register an already signed, still-valid license token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [token]
|
||||
properties:
|
||||
token: {type: string, minLength: 20}
|
||||
customerUserId: {type: string}
|
||||
responses:
|
||||
'201':
|
||||
description: Imported license
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/License'}
|
||||
'400': {$ref: '#/components/responses/Error'}
|
||||
'401': {$ref: '#/components/responses/Error'}
|
||||
/api/v1/licenses/{licenseId}/revoke:
|
||||
post:
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Revoke a registered license
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/LicenseId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason: {type: string, maxLength: 500}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Mutation'}
|
||||
'401': {$ref: '#/components/responses/Error'}
|
||||
'404': {$ref: '#/components/responses/Error'}
|
||||
/api/v1/licenses/{licenseId}/restore:
|
||||
post:
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Restore a revoked license
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/LicenseId'
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Mutation'}
|
||||
'401': {$ref: '#/components/responses/Error'}
|
||||
'404': {$ref: '#/components/responses/Error'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
parameters:
|
||||
LicenseId:
|
||||
name: licenseId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
Error:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error: {type: string}
|
||||
reason: {type: string}
|
||||
Mutation:
|
||||
description: License status changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [licenseId, revoked]
|
||||
properties:
|
||||
licenseId: {type: string}
|
||||
revoked: {type: boolean}
|
||||
schemas:
|
||||
Discovery:
|
||||
type: object
|
||||
required: [issuer, serverUrl, validationEndpoint, trustStoreEndpoint]
|
||||
properties:
|
||||
issuer: {type: string}
|
||||
serverUrl: {type: string, format: uri}
|
||||
validationEndpoint: {type: string, format: uri}
|
||||
trustStoreEndpoint: {type: string, format: uri}
|
||||
TrustStore:
|
||||
type: object
|
||||
required: [licenseKeys, leaseKeys]
|
||||
properties:
|
||||
licenseKeys:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
leaseKeys:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
ValidationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [token, product, baseUrl]
|
||||
properties:
|
||||
token: {type: string, minLength: 20}
|
||||
product: {type: string, minLength: 1}
|
||||
baseUrl: {type: string, format: uri}
|
||||
host: {type: string}
|
||||
instanceId: {type: string}
|
||||
clientVersion: {type: string}
|
||||
ValidationResponse:
|
||||
type: object
|
||||
required: [valid]
|
||||
properties:
|
||||
valid: {type: boolean}
|
||||
leaseToken: {type: string}
|
||||
expiresAt: {type: string, format: date-time}
|
||||
reason: {type: string}
|
||||
IssueLicense:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [customer, product, edition, mode, days]
|
||||
properties:
|
||||
customerUserId: {type: string}
|
||||
customer: {type: string, minLength: 1}
|
||||
product: {type: string, minLength: 1}
|
||||
edition: {type: string, minLength: 1}
|
||||
features:
|
||||
type: array
|
||||
items: {type: string}
|
||||
uniqueItems: true
|
||||
limits:
|
||||
type: object
|
||||
additionalProperties: {type: integer, minimum: 0}
|
||||
domains:
|
||||
type: array
|
||||
items: {type: string}
|
||||
uniqueItems: true
|
||||
instanceIds:
|
||||
type: array
|
||||
items: {type: string}
|
||||
uniqueItems: true
|
||||
mode: {enum: [offline, hybrid, online]}
|
||||
days: {type: integer, minimum: 1, maximum: 3650}
|
||||
leaseMinutes: {type: integer, minimum: 1, maximum: 1440, default: 60}
|
||||
graceHours: {type: integer, minimum: 0, maximum: 8760, default: 72}
|
||||
License:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/IssueLicense'
|
||||
- type: object
|
||||
required: [licenseId, issuer, serverUrl, expiresAt, revoked, createdAt, updatedAt]
|
||||
properties:
|
||||
licenseId: {type: string}
|
||||
issuer: {type: string}
|
||||
serverUrl: {type: string, format: uri}
|
||||
expiresAt: {type: integer}
|
||||
revoked: {type: boolean}
|
||||
reason: {type: string}
|
||||
token:
|
||||
type: string
|
||||
description: Present only in create and explicitly authorized token-delivery responses.
|
||||
createdAt: {type: integer}
|
||||
updatedAt: {type: integer}
|
||||
3
pkg/licensekit/doc.go
Normal file
3
pkg/licensekit/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package licensekit implements product-neutral Ed25519 licence and lease
|
||||
// tokens, embedded trust stores, context validation and key rotation by key ID.
|
||||
package licensekit
|
||||
479
pkg/licensekit/licensekit.go
Normal file
479
pkg/licensekit/licensekit.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenTypeLicense = "LICENSE"
|
||||
TokenTypeLease = "LEASE"
|
||||
AlgorithmEdDSA = "EdDSA"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
type VerificationMode string
|
||||
|
||||
const (
|
||||
ModeOffline VerificationMode = "offline"
|
||||
ModeHybrid VerificationMode = "hybrid"
|
||||
ModeOnline VerificationMode = "online"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version int `json:"v"`
|
||||
}
|
||||
|
||||
type VerificationPolicy struct {
|
||||
Mode VerificationMode `json:"mode"`
|
||||
LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"`
|
||||
OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version int `json:"version"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Issuer string `json:"issuer"`
|
||||
Customer string `json:"customer"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
NotBefore int64 `json:"notBefore,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Verification VerificationPolicy `json:"verification"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type LeaseClaims struct {
|
||||
Version int `json:"version"`
|
||||
LeaseID string `json:"leaseId"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Product string `json:"product"`
|
||||
Customer string `json:"customer"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type TrustStore struct {
|
||||
LicenseKeys map[string]string `json:"licenseKeys"`
|
||||
LeaseKeys map[string]string `json:"leaseKeys"`
|
||||
}
|
||||
|
||||
type VerifiedLicense struct {
|
||||
Header Header
|
||||
Claims Claims
|
||||
}
|
||||
|
||||
type VerifiedLease struct {
|
||||
Header Header
|
||||
Claims LeaseClaims
|
||||
}
|
||||
|
||||
func NewTrustStore() TrustStore {
|
||||
return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}}
|
||||
}
|
||||
|
||||
func ParseTrustStore(data []byte) (TrustStore, error) {
|
||||
var store TrustStore
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&store); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("decode trust store: %w", err)
|
||||
}
|
||||
if store.LicenseKeys == nil {
|
||||
store.LicenseKeys = map[string]string{}
|
||||
}
|
||||
if store.LeaseKeys == nil {
|
||||
store.LeaseKeys = map[string]string{}
|
||||
}
|
||||
for kid, encoded := range store.LicenseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("license trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
for kid, encoded := range store.LeaseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("lease trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func MarshalTrustStore(store TrustStore) ([]byte, error) {
|
||||
if store.LicenseKeys == nil {
|
||||
store.LicenseKeys = map[string]string{}
|
||||
}
|
||||
if store.LeaseKeys == nil {
|
||||
store.LeaseKeys = map[string]string{}
|
||||
}
|
||||
return json.MarshalIndent(store, "", " ")
|
||||
}
|
||||
|
||||
func SignLicense(privateKey ed25519.PrivateKey, keyID string, claims Claims) (string, error) {
|
||||
if err := validateLicenseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sign(TokenTypeLicense, keyID, privateKey, claims)
|
||||
}
|
||||
|
||||
func SignLease(privateKey ed25519.PrivateKey, keyID string, claims LeaseClaims) (string, error) {
|
||||
if err := validateLeaseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sign(TokenTypeLease, keyID, privateKey, claims)
|
||||
}
|
||||
|
||||
func sign(tokenType, keyID string, privateKey ed25519.PrivateKey, claims any) (string, error) {
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return "", errors.New("invalid Ed25519 private key")
|
||||
}
|
||||
keyID = strings.TrimSpace(keyID)
|
||||
if keyID == "" {
|
||||
return "", errors.New("key id is required")
|
||||
}
|
||||
header := Header{Algorithm: AlgorithmEdDSA, Type: tokenType, KeyID: keyID, Version: SchemaVersion}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal token header: %w", err)
|
||||
}
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal token payload: %w", err)
|
||||
}
|
||||
headerPart := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
payloadPart := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
signingInput := headerPart + "." + payloadPart
|
||||
signature := ed25519.Sign(privateKey, []byte(signingInput))
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) {
|
||||
header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token)
|
||||
if err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
var claims Claims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err)
|
||||
}
|
||||
if err := validateLicenseClaims(claims, now, true); err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
claims.Domains = UniqueSorted(claims.Domains)
|
||||
claims.InstanceIDs = UniqueSorted(claims.InstanceIDs)
|
||||
return VerifiedLicense{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) {
|
||||
header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token)
|
||||
if err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
var claims LeaseClaims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err)
|
||||
}
|
||||
if err := validateLeaseClaims(claims, now, false); err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) {
|
||||
return VerifiedLease{}, errors.New("lease has expired")
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
return VerifiedLease{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 3 {
|
||||
return Header{}, nil, errors.New("token has invalid format")
|
||||
}
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token header is not valid base64url")
|
||||
}
|
||||
var header Header
|
||||
if err := decodeStrict(headerBytes, &header); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode token header: %w", err)
|
||||
}
|
||||
if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion {
|
||||
return Header{}, nil, errors.New("unsupported token header")
|
||||
}
|
||||
encodedKey, ok := keys[header.KeyID]
|
||||
if !ok {
|
||||
return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID)
|
||||
}
|
||||
publicKey, err := DecodePublicKey(encodedKey)
|
||||
if err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err)
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token signature is not valid base64url")
|
||||
}
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
|
||||
return Header{}, nil, errors.New("token signature verification failed")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token payload is not valid base64url")
|
||||
}
|
||||
return header, payload, nil
|
||||
}
|
||||
|
||||
func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error {
|
||||
if strings.TrimSpace(product) == "" {
|
||||
return errors.New("client product id is required")
|
||||
}
|
||||
if claims.Product != product {
|
||||
return fmt.Errorf("license is for product %q, not %q", claims.Product, product)
|
||||
}
|
||||
if err := ValidateDomain(claims.Domains, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(claims.InstanceIDs) > 0 {
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
if instanceID == "" {
|
||||
return errors.New("license requires an instance id")
|
||||
}
|
||||
allowed := false
|
||||
for _, candidate := range claims.InstanceIDs {
|
||||
if candidate == "*" || candidate == instanceID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("instance %q is not covered by the license", instanceID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error {
|
||||
if claims.LicenseID != license.LicenseID {
|
||||
return errors.New("lease does not belong to the configured license")
|
||||
}
|
||||
if claims.Product != product || claims.Product != license.Product {
|
||||
return errors.New("lease product does not match")
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if claims.Host != "" && !strings.EqualFold(claims.Host, host) {
|
||||
return errors.New("lease host does not match")
|
||||
}
|
||||
if claims.InstanceID != "" && claims.InstanceID != instanceID {
|
||||
return errors.New("lease instance does not match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateDomain(domains []string, baseURL string) error {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, allowed := range domains {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == "*" || host == allowed {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
root := strings.TrimPrefix(allowed, "*.")
|
||||
if host != root && strings.HasSuffix(host, "."+root) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("host %q is not covered by the license", host)
|
||||
}
|
||||
|
||||
func HostFromBaseURL(baseURL string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", errors.New("base URL has no valid host")
|
||||
}
|
||||
return strings.ToLower(u.Hostname()), nil
|
||||
}
|
||||
|
||||
func StricterMode(a, b VerificationMode) VerificationMode {
|
||||
rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
if _, ok := rank[a]; !ok {
|
||||
return ModeOffline
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ParseMode(value string) (VerificationMode, error) {
|
||||
mode := VerificationMode(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch mode {
|
||||
case "", ModeOffline:
|
||||
return ModeOffline, nil
|
||||
case ModeHybrid, ModeOnline:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown verification mode %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == ed25519.SeedSize {
|
||||
return ed25519.NewKeyFromSeed(b), nil
|
||||
}
|
||||
if len(b) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("private key must contain an Ed25519 seed or private key")
|
||||
}
|
||||
return ed25519.PrivateKey(b), nil
|
||||
}
|
||||
|
||||
func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("public key must contain an Ed25519 public key")
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
|
||||
func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) }
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("key is not valid base64")
|
||||
}
|
||||
|
||||
func UniqueSorted(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validateLicenseClaims(c Claims, now time.Time, checkTime bool) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported license version")
|
||||
}
|
||||
if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" {
|
||||
return errors.New("license id and issuer are required")
|
||||
}
|
||||
if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" {
|
||||
return errors.New("customer, product and edition are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("license timestamps are invalid")
|
||||
}
|
||||
notBefore := c.NotBefore
|
||||
if notBefore == 0 {
|
||||
notBefore = c.IssuedAt
|
||||
}
|
||||
if checkTime {
|
||||
if now.Unix() < notBefore-300 {
|
||||
return errors.New("license is not active yet")
|
||||
}
|
||||
if now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("license has expired")
|
||||
}
|
||||
}
|
||||
if _, err := ParseMode(string(c.Verification.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 {
|
||||
return errors.New("verification durations cannot be negative")
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return errors.New("verification server URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLeaseClaims(c LeaseClaims, now time.Time, checkExpiration bool) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported lease version")
|
||||
}
|
||||
if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" {
|
||||
return errors.New("lease id, license id and product are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("lease timestamps are invalid")
|
||||
}
|
||||
if now.Unix() < c.IssuedAt-300 {
|
||||
return errors.New("lease is not active yet")
|
||||
}
|
||||
if checkExpiration && now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("lease has expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStrict(data []byte, target any) error {
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
84
pkg/licensekit/licensekit_test.go
Normal file
84
pkg/licensekit/licensekit_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKeys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestLicenseRoundTripAndContext(t *testing.T) {
|
||||
pub, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product-a", Edition: "pro", Features: []string{"b", "a"}, Domains: []string{"*.example.org"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
|
||||
token, err := SignLicense(priv, "issuer-1", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewTrustStore()
|
||||
store.LicenseKeys["issuer-1"] = EncodeKey(pub)
|
||||
verified, err := VerifyLicense(store, token, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.Claims.Features[0] != "a" {
|
||||
t.Fatalf("features not sorted: %#v", verified.Claims.Features)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "product-a", "https://app.example.org", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "product-b", "https://app.example.org", ""); err == nil {
|
||||
t.Fatal("expected product mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalWildcardAllowsAllHosts(t *testing.T) {
|
||||
if err := ValidateDomain([]string{"*"}, "http://localhost:8080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateDomain([]string{"*"}, "https://anything.invalid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownKeyIsRejected(t *testing.T) {
|
||||
_, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
|
||||
token, err := SignLicense(priv, "self-chosen", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := VerifyLicense(NewTrustStore(), token, now); err == nil {
|
||||
t.Fatal("untrusted user key must not be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseRoundTrip(t *testing.T) {
|
||||
pub, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: "lic_1", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"x"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()}
|
||||
token, err := SignLease(priv, "lease-1", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewTrustStore()
|
||||
store.LeaseKeys["lease-1"] = EncodeKey(pub)
|
||||
verified, err := VerifyLease(store, token, now, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
license := Claims{LicenseID: "lic_1", Product: "product"}
|
||||
if err := ValidateLeaseContext(verified.Claims, license, "product", "https://example.org", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
35
run.ps1
Normal file
35
run.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$envFile = Join-Path $PSScriptRoot ".env"
|
||||
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "Keine .env-Datei gefunden: $envFile"
|
||||
}
|
||||
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
|
||||
if ($line -and -not $line.StartsWith("#")) {
|
||||
$parts = $line -split "=", 2
|
||||
|
||||
if ($parts.Count -eq 2) {
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
|
||||
if (
|
||||
($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))
|
||||
) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
$name,
|
||||
$value,
|
||||
"Process"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go run .\cmd\server
|
||||
450
sdk/go/licenseclient/client.go
Normal file
450
sdk/go/licenseclient/client.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Edition string `json:"edition"`
|
||||
Licensed bool `json:"licensed"`
|
||||
LicenseID string `json:"licenseId,omitempty"`
|
||||
Customer string `json:"customer,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Features []string `json:"features"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LastChecked string `json:"lastChecked,omitempty"`
|
||||
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Product string
|
||||
Token string
|
||||
TrustStore licensekit.TrustStore
|
||||
BaseURL string
|
||||
InstanceID string
|
||||
Mode licensekit.VerificationMode
|
||||
ServerURL string
|
||||
CacheFile string
|
||||
RefreshEvery time.Duration
|
||||
RequestTimeout time.Duration
|
||||
ClientVersion string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
claims licensekit.Claims
|
||||
features map[string]bool
|
||||
stopOnce sync.Once
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
Product string `json:"product"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Host string `json:"host"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
}
|
||||
|
||||
type introspectResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type cacheDocument struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
Lease string `json:"lease"`
|
||||
SavedAt int64 `json:"savedAt"`
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg Config) *Client {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||
if cfg.ServerURL == "" {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/")
|
||||
}
|
||||
if cfg.RefreshEvery <= 0 {
|
||||
cfg.RefreshEvery = 15 * time.Minute
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
|
||||
}
|
||||
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
|
||||
c.refresh(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
c.mu.RLock()
|
||||
mode := c.status.Mode
|
||||
c.mu.RUnlock()
|
||||
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(c.cfg.RefreshEvery)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
|
||||
c.refresh(refreshCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
|
||||
|
||||
func (c *Client) Refresh(ctx context.Context) Status {
|
||||
c.refresh(ctx)
|
||||
return c.Status()
|
||||
}
|
||||
|
||||
func (c *Client) Has(feature string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
|
||||
}
|
||||
|
||||
func (c *Client) Limit(name string) (int64, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
return 0, false
|
||||
}
|
||||
value, ok := c.status.Limits[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (c *Client) Status() Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := c.status
|
||||
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
out.Licensed = false
|
||||
out.Edition = "community"
|
||||
if out.Reason == "" {
|
||||
out.Reason = "license or online lease is no longer valid"
|
||||
}
|
||||
}
|
||||
out.Features = append([]string{}, c.status.Features...)
|
||||
if c.status.Limits != nil {
|
||||
out.Limits = make(map[string]int64, len(c.status.Limits))
|
||||
for key, value := range c.status.Limits {
|
||||
out.Limits[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
|
||||
if !c.status.Licensed {
|
||||
return false
|
||||
}
|
||||
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
|
||||
return false
|
||||
}
|
||||
mode := licensekit.VerificationMode(c.status.Mode)
|
||||
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
|
||||
return true
|
||||
}
|
||||
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if mode == licensekit.ModeHybrid {
|
||||
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
|
||||
}
|
||||
return now.Before(leaseExpiry)
|
||||
}
|
||||
|
||||
func (c *Client) refresh(ctx context.Context) {
|
||||
now := time.Now().UTC()
|
||||
if strings.TrimSpace(c.cfg.Token) == "" {
|
||||
c.apply(communityStatus(), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
|
||||
if err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
claims := verified.Claims
|
||||
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), claims)
|
||||
return
|
||||
}
|
||||
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
|
||||
base := statusFromClaims(claims, mode, now)
|
||||
if mode == licensekit.ModeOffline {
|
||||
base.Licensed = true
|
||||
base.Source = "offline"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
serverURL := c.resolveServerURL(ctx, claims)
|
||||
base.ServerURL = serverURL
|
||||
if serverURL == "" {
|
||||
base.Edition = "community"
|
||||
base.Reason = "online verification is required but no license server URL could be resolved"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL)
|
||||
if err != nil {
|
||||
base.Edition = "community"
|
||||
base.Reason = err.Error()
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
base.Licensed = true
|
||||
base.Source = source
|
||||
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
|
||||
c.apply(base, claims)
|
||||
}
|
||||
|
||||
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time, serverURL string) (licensekit.LeaseClaims, string, error) {
|
||||
leaseToken, err := c.requestLease(ctx, serverURL)
|
||||
if err == nil {
|
||||
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
|
||||
if verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
|
||||
}
|
||||
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", verifyErr
|
||||
}
|
||||
_ = c.writeCache(claims.LicenseID, leaseToken)
|
||||
return lease.Claims, "online", nil
|
||||
}
|
||||
if mode == licensekit.ModeOnline {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
|
||||
}
|
||||
cached, cacheErr := c.readCache(claims, now)
|
||||
if cacheErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
|
||||
}
|
||||
return cached, "cached-lease", nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveServerURL(ctx context.Context, claims licensekit.Claims) string {
|
||||
if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var discovery struct {
|
||||
ServerURL string `json:"serverUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/")
|
||||
}
|
||||
|
||||
func (c *Client) requestLease(ctx context.Context, serverURL string) (string, error) {
|
||||
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paths := []string{"/api/v1/licenses/validate", "/v1/introspect"}
|
||||
var lastErr error
|
||||
for index, path := range paths {
|
||||
token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
lastErr = err
|
||||
if index == 0 && status == http.StatusNotFound {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
var result introspectResponse
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
|
||||
if result.Reason == "" {
|
||||
result.Reason = resp.Status
|
||||
}
|
||||
return "", resp.StatusCode, errors.New(result.Reason)
|
||||
}
|
||||
return result.LeaseToken, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
|
||||
}
|
||||
data, err := os.ReadFile(c.cfg.CacheFile)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
var doc cacheDocument
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if doc.LicenseID != claims.LicenseID {
|
||||
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
|
||||
}
|
||||
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
|
||||
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
return verified.Claims, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeCache(licenseID, lease string) error {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := c.cfg.CacheFile + ".tmp"
|
||||
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, c.cfg.CacheFile)
|
||||
}
|
||||
|
||||
func (c *Client) apply(status Status, claims licensekit.Claims) {
|
||||
status.Features = licensekit.UniqueSorted(status.Features)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status = status
|
||||
c.claims = claims
|
||||
c.features = make(map[string]bool, len(status.Features))
|
||||
for _, feature := range status.Features {
|
||||
c.features[feature] = true
|
||||
}
|
||||
}
|
||||
|
||||
func communityStatus() Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
|
||||
}
|
||||
|
||||
func failedStatus(reason string, now time.Time) Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
|
||||
limits := map[string]int64{}
|
||||
for key, value := range claims.Limits {
|
||||
limits[key] = value
|
||||
}
|
||||
return Status{
|
||||
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
|
||||
Features: append([]string(nil), claims.Features...), Limits: limits,
|
||||
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func intersection(a, b []string) []string {
|
||||
allowed := make(map[string]bool, len(b))
|
||||
for _, value := range b {
|
||||
allowed[value] = true
|
||||
}
|
||||
out := make([]string, 0, len(a))
|
||||
for _, value := range a {
|
||||
if allowed[value] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewDevelopment returns an in-memory licensed client for local development.
|
||||
// Production applications should not expose this path without an explicit development switch.
|
||||
func NewDevelopment(product, edition string, features []string) *Client {
|
||||
status := Status{Edition: edition, Licensed: true, Customer: "development", Product: product, Features: licensekit.UniqueSorted(features), Limits: map[string]int64{}, Mode: string(licensekit.ModeOffline), Source: "development", Reason: "insecure development override"}
|
||||
c := &Client{status: status, features: map[string]bool{}, stop: make(chan struct{})}
|
||||
for _, feature := range status.Features {
|
||||
c.features[feature] = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
113
sdk/go/licenseclient/client_test.go
Normal file
113
sdk/go/licenseclient/client_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
func keys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func licenseToken(t *testing.T, mode licensekit.VerificationMode, store *licensekit.TrustStore) (string, licensekit.Claims) {
|
||||
t.Helper()
|
||||
pub, priv := keys(t)
|
||||
store.LicenseKeys["issuer"] = licensekit.EncodeKey(pub)
|
||||
now := time.Now().UTC()
|
||||
claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature_a", "feature_b"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: 600, OfflineGraceSeconds: 3600}}
|
||||
token, err := licensekit.SignLicense(priv, "issuer", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token, claims
|
||||
}
|
||||
|
||||
func TestOfflineClient(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, _ := licenseToken(t, licensekit.ModeOffline, &store)
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org"})
|
||||
if !c.Status().Licensed || !c.Has("feature_a") {
|
||||
t.Fatalf("unexpected status %#v", c.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridClientUsesOnlineLeaseAndCache(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, claims := licenseToken(t, licensekit.ModeHybrid, &store)
|
||||
leasePub, leasePriv := keys(t)
|
||||
store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UTC()
|
||||
lease := licensekit.LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(5 * time.Minute).Unix()}
|
||||
leaseToken, err := licensekit.SignLease(leasePriv, "lease", lease)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": leaseToken})
|
||||
}))
|
||||
cache := filepath.Join(t.TempDir(), "lease.json")
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid, ServerURL: server.URL, CacheFile: cache})
|
||||
if !c.Status().Licensed || c.Status().Source != "online" {
|
||||
t.Fatalf("unexpected online status %#v", c.Status())
|
||||
}
|
||||
server.Close()
|
||||
c.Refresh(context.Background())
|
||||
if !c.Status().Licensed || c.Status().Source != "cached-lease" {
|
||||
t.Fatalf("unexpected cached status %#v", c.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineModeFailsWithoutServer(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, _ := licenseToken(t, licensekit.ModeOnline, &store)
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeOnline})
|
||||
if c.Status().Licensed {
|
||||
t.Fatalf("online license unexpectedly active %#v", c.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsesServerURLEmbeddedInLicense(t *testing.T) {
|
||||
issuerPub, issuerPriv := keys(t)
|
||||
leasePub, leasePriv := keys(t)
|
||||
store := licensekit.NewTrustStore()
|
||||
store.LicenseKeys["issuer"] = licensekit.EncodeKey(issuerPub)
|
||||
store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
|
||||
now := time.Now().UTC()
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/licenses/validate" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
lease, err := licensekit.SignLease(leasePriv, "lease", licensekit.LeaseClaims{Version: 1, LeaseID: "lease", LicenseID: "license", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"feature"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": lease})
|
||||
}))
|
||||
defer server.Close()
|
||||
claims := licensekit.Claims{Version: 1, LicenseID: "license", Issuer: "issuer", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, ServerURL: server.URL}}
|
||||
token, err := licensekit.SignLicense(issuerPriv, "issuer", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid})
|
||||
status := client.Status()
|
||||
if !status.Licensed || status.ServerURL != server.URL {
|
||||
t.Fatalf("embedded URL was not used: %#v", status)
|
||||
}
|
||||
}
|
||||
4
sdk/go/licenseclient/doc.go
Normal file
4
sdk/go/licenseclient/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package licenseclient implements the runtime side of product licensing. It
|
||||
// supports offline verification, hybrid signed-lease caching and mandatory
|
||||
// online introspection without allowing customers to replace trusted keys.
|
||||
package licenseclient
|
||||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package webassets
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS contains the portal templates and static assets.
|
||||
//
|
||||
//go:embed templates/*.html static/*
|
||||
var FS embed.FS
|
||||
2
web/static/app.css
Normal file
2
web/static/app.css
Normal file
File diff suppressed because one or more lines are too long
35
web/templates/login.html
Normal file
35
web/templates/login.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Login · {{.Brand}}</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<main class="auth-shell">
|
||||
<section class="auth-brand">
|
||||
<div class="brand-mark" aria-hidden="true">L</div>
|
||||
<p class="eyebrow">License infrastructure</p>
|
||||
<h1>Eine zentrale Autorität für jede Produktlizenz.</h1>
|
||||
<p>Offline signierte Lizenzen, kurzlebige Online-Leases und getrennte Portale für Management, Reseller und Kunden.</p>
|
||||
<div class="trust-row"><span>Ed25519</span><span>AES-256-GCM</span><span>Hybrid offline</span></div>
|
||||
</section>
|
||||
<section class="auth-card">
|
||||
<div>
|
||||
<p class="eyebrow">Geschützter Bereich</p>
|
||||
<h2>Anmelden</h2>
|
||||
<p class="muted">Verwende deinen persönlichen Account. Zugriffe werden protokolliert.</p>
|
||||
</div>
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login" class="stack">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Passwort<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button class="button primary" type="submit">Sicher anmelden</button>
|
||||
</form>
|
||||
<p class="micro">Sessions sind HttpOnly, SameSite=Strict und zeitlich begrenzt.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
96
web/templates/portal.html
Normal file
96
web/templates/portal.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.PortalTitle}} · {{.Brand}}</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="brand-mark small">L</span><span>{{.Brand}}</span></a>
|
||||
<div class="top-actions"><span class="role-pill">{{.RoleLabel}}</span><span class="user-name">{{.User.DisplayName}}</span><form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button ghost" type="submit">Abmelden</button></form></div>
|
||||
</header>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div><p class="eyebrow">{{.PortalTitle}}</p><h1>{{.Headline}}</h1><p>{{.Subline}}</p></div>
|
||||
<div class="status-card"><span class="status-dot {{if .KeysReady}}ok{{else}}warn{{end}}"></span><div><strong>{{if .KeysReady}}Signaturdienst bereit{{else}}Initialisierung erforderlich{{end}}</strong><small>{{if .KeysReady}}Private Keys verschlüsselt gespeichert{{else}}Schlüsselpaare noch nicht erzeugt{{end}}</small></div></div>
|
||||
</section>
|
||||
{{if .Message}}<div class="alert alert-success">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<section class="metrics">
|
||||
<article><span>Aktive Lizenzen</span><strong>{{.ActiveCount}}</strong><small>im sichtbaren Mandantenbereich</small></article>
|
||||
<article><span>Auslaufend</span><strong>{{.ExpiringCount}}</strong><small>innerhalb von 30 Tagen</small></article>
|
||||
<article><span>Widerrufen</span><strong>{{.RevokedCount}}</strong><small>sofort serverseitig gesperrt</small></article>
|
||||
<article><span>Validierungs-API</span><strong>Online</strong><small>/api/v1/licenses/validate</small></article>
|
||||
</section>
|
||||
|
||||
{{if eq .User.Role "admin"}}
|
||||
<section class="grid two">
|
||||
<article class="panel accent-panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Root of trust</p><h2>Schlüsselverwaltung</h2></div><span class="badge">Write-once</span></div>
|
||||
{{if .KeysReady}}
|
||||
<p class="muted">Die Schlüsselpaare sind erzeugt. Eine erneute Generierung ist in Oberfläche und Backend gesperrt.</p>
|
||||
<dl class="key-list"><div><dt>Issuer Key ID</dt><dd>{{.Keys.IssuerKeyID}}</dd></div><div><dt>Lease Key ID</dt><dd>{{.Keys.LeaseKeyID}}</dd></div><div><dt>Erstellt</dt><dd>{{formatTime .Keys.CreatedAt}}</dd></div></dl>
|
||||
<label>Öffentlicher Trust Store<textarea readonly rows="7">{{.TrustStoreJSON}}</textarea></label>
|
||||
{{else}}
|
||||
<p>Erzeuge genau einmal die langfristige Issuer- und die kurzlebige Lease-Autorität. Private Schlüssel werden vor dem Speichern mit dem Master-Key verschlüsselt.</p>
|
||||
<form method="post" action="/admin/keys/generate" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Issuer Key ID<input name="issuerKeyId" value="issuer-2026" required></label><label>Lease Key ID<input name="leaseKeyId" value="lease-2026" required></label><button class="button primary" type="submit">Schlüsselpaare einmalig erzeugen</button></form>
|
||||
<details class="key-import"><summary>Bestehende Schlüsselpaare sicher importieren</summary><form method="post" action="/admin/keys/import" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="form-grid"><label>Issuer Key ID<input name="issuerKeyId" required></label><label>Lease Key ID<input name="leaseKeyId" required></label></div><label>Issuer Private Key<input type="password" name="issuerPrivate" autocomplete="off" required></label><label>Lease Private Key<input type="password" name="leasePrivate" autocomplete="off" required></label><button class="button ghost" type="submit">Einmalig importieren und sperren</button></form></details>
|
||||
{{end}}
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Identity & access</p><h2>Benutzer anlegen</h2></div></div>
|
||||
<form method="post" action="/admin/users" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="form-grid"><label>Benutzername<input name="username" required></label><label>Anzeigename<input name="displayName" required></label></div><div class="form-grid"><label>Rolle<select name="role"><option value="reseller">Reseller / Autor</option><option value="customer">Kunde / Client</option><option value="admin">Admin / Management</option></select></label><label>Initialpasswort<input type="password" name="password" minlength="12" required></label></div><label>Reseller-Zuordnung (nur für Kunden)<select name="resellerUserId"><option value="">Direktkunde / keine Zuordnung</option>{{range .Resellers}}<option value="{{.ID}}">{{.DisplayName}} (@{{.Username}})</option>{{end}}</select></label><button class="button secondary" type="submit">Account erstellen</button></form>
|
||||
<div class="user-list">{{range .Users}}<div><span class="avatar">{{initial .DisplayName}}</span><div><strong>{{.DisplayName}}</strong><small>@{{.Username}} · {{.Role}}</small></div></div>{{end}}</div>
|
||||
</article>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if eq .User.Role "reseller"}}
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Customer onboarding</p><h2>Kundenportal anlegen</h2></div><span class="badge">Mandantentrennung</span></div>
|
||||
<p class="muted">Der neue Kunde wird deinem Reseller-Account fest zugeordnet und ist für andere Reseller nicht sichtbar.</p>
|
||||
<form method="post" action="/reseller/customers" class="form-grid three"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Benutzername<input name="username" required></label><label>Anzeigename<input name="displayName" required></label><label>Initialpasswort<input type="password" name="password" minlength="12" required></label><button class="button secondary" type="submit">Kundenportal erstellen</button></form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="panel account-panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Account security</p><h2>Passwort ändern</h2></div><span class="badge">Session reset</span></div>
|
||||
<form method="post" action="/account/password" class="form-grid three"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort<input type="password" name="currentPassword" autocomplete="current-password" required></label><label>Neues Passwort<input type="password" name="newPassword" minlength="12" autocomplete="new-password" required></label><label> <button class="button ghost" type="submit">Passwort aktualisieren</button></label></form>
|
||||
</section>
|
||||
|
||||
{{if or (eq .User.Role "admin") (eq .User.Role "reseller")}}
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">License composer</p><h2>Neue Lizenz ausstellen</h2></div><span class="badge badge-live">signiert</span></div>
|
||||
{{if .KeysReady}}
|
||||
<form method="post" action="/licenses" class="stack"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid three"><label>Kunden-Account<select name="customerUserId"><option value="">Ohne Portal-Zuordnung</option>{{range .Customers}}<option value="{{.ID}}">{{.DisplayName}} (@{{.Username}})</option>{{end}}</select></label><label>Kundenname<input name="customer" required></label><label>Produkt-ID<input name="product" placeholder="my-product" required></label></div>
|
||||
<div class="form-grid three"><label>Edition<input name="edition" placeholder="professional" required></label><label>Modus<select name="mode"><option value="hybrid">Hybrid offline</option><option value="online">Online erforderlich</option><option value="offline">Vollständig offline</option></select></label><label>Gültigkeit (Tage)<input type="number" name="days" min="1" max="3650" value="365" required></label></div>
|
||||
<div class="form-grid"><label>Features<textarea name="features" placeholder="advanced_export, white_label"></textarea></label><label>Limits<textarea name="limits" placeholder="users=50 sites=10"></textarea></label></div>
|
||||
<div class="form-grid"><label>Domains<input name="domains" value="*" placeholder="example.org, *.example.org"></label><label>Instanz-IDs<input name="instances" placeholder="optional"></label></div>
|
||||
<div class="form-grid three"><label>Lease TTL (Minuten)<input type="number" name="leaseMinutes" min="1" max="1440" value="60"></label><label>Offline Grace (Stunden)<input type="number" name="graceHours" min="0" max="8760" value="72"></label><label>Server-URL<input value="{{.PublicURL}}" readonly></label></div>
|
||||
<button class="button primary" type="submit">Lizenz signieren und registrieren</button>
|
||||
</form>
|
||||
{{else}}<div class="empty">Ein Administrator muss zuerst die Schlüsselpaare initialisieren.</div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Portfolio</p><h2>{{if eq .User.Role "customer"}}Meine Lizenzen{{else}}Lizenzbestand{{end}}</h2></div><span class="badge">{{len .Licenses}} Einträge</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Lizenz</th><th>Kunde / Produkt</th><th>Modus</th><th>Status</th><th>Gültig bis</th><th>Aktion</th></tr></thead><tbody>
|
||||
{{range .Licenses}}<tr><td><strong>{{.LicenseID}}</strong><small>{{.Edition}}</small></td><td>{{.Customer}}<small>{{.Product}}</small></td><td><span class="mode">{{.Mode}}</span></td><td>{{if .Revoked}}<span class="status revoked">Widerrufen</span>{{else}}<span class="status active">Aktiv</span>{{end}}</td><td>{{formatTime .ExpiresAt}}</td><td><div class="row-actions"><a class="button tiny ghost" href="/licenses/{{.LicenseID}}/token">Token</a>{{if or (eq $.User.Role "admin") (and (eq $.User.Role "reseller") (eq .IssuedByUserID $.User.ID))}}<form method="post" action="/licenses/{{.LicenseID}}/{{if .Revoked}}restore{{else}}revoke{{end}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button tiny {{if .Revoked}}secondary{{else}}danger{{end}}" type="submit">{{if .Revoked}}Aktivieren{{else}}Sperren{{end}}</button></form>{{end}}</div></td></tr>{{else}}<tr><td colspan="6"><div class="empty">Noch keine Lizenzen vorhanden.</div></td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
|
||||
{{if eq .User.Role "admin"}}
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><p class="eyebrow">Audit trail</p><h2>Letzte Sicherheitsereignisse</h2></div></div>
|
||||
<div class="audit-list">{{range .Audit}}<div><time>{{formatTime .CreatedAt}}</time><strong>{{.Action}}</strong><span>{{.Target}}</span><small>{{.Detail}}</small></div>{{else}}<div class="empty">Noch keine Ereignisse.</div>{{end}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
<footer><span>{{.Brand}}</span><span>Ed25519 · AES-256-GCM · CSRF · Audit</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
6
web/templates/token.html
Normal file
6
web/templates/token.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lizenz-Token · {{.Brand}}</title><link rel="stylesheet" href="/assets/app.css"></head><body><header class="topbar"><a class="brand" href="/"><span class="brand-mark small">L</span><span>{{.Brand}}</span></a><a class="button ghost" href="/">Zurück</a></header><main class="shell narrow"><section class="hero"><div><p class="eyebrow">Credential delivery</p><h1>{{.License.LicenseID}}</h1><p>Dieser Token enthält keine privaten Schlüssel. Er kann als Secret im Zielsystem hinterlegt werden.</p></div></section><section class="panel"><div class="panel-head"><div><h2>Client-Konfiguration</h2></div><span class="badge">{{.License.Mode}}</span></div><label>Environment<textarea rows="8" readonly>LICENSE_TOKEN={{.Token}}
|
||||
LICENSE_MODE={{.License.Mode}}
|
||||
LICENSE_SERVER_URL={{.License.ServerURL}}
|
||||
LICENSE_CACHE_FILE=./data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL=15m
|
||||
LICENSE_REQUEST_TIMEOUT=5s</textarea></label><label>Signierter Lizenz-Token<textarea rows="12" readonly>{{.Token}}</textarea></label><div class="alert">Die Server-URL ist zusätzlich signiert im Token hinterlegt. Moderne Clients erkennen sie automatisch; ENV überschreibt die automatische Erkennung.</div></section></main></body></html>
|
||||
Reference in New Issue
Block a user