This commit is contained in:
2026-07-20 21:41:51 +02:00
parent 355266df01
commit 91081f6501
35 changed files with 4203 additions and 1 deletions
+129
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 &copy, 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
}