706 lines
24 KiB
Go
706 lines
24 KiB
Go
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())
|
||
})
|
||
}
|