init
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user