diff --git a/internal/domain/identity/identity_test.go b/internal/domain/identity/identity_test.go index f04bdb0..0acbed4 100644 --- a/internal/domain/identity/identity_test.go +++ b/internal/domain/identity/identity_test.go @@ -119,7 +119,7 @@ func TestStoreRegisterAndGet(t *testing.T) { agent := &AgentIdentity{ AgentID: "agent-01", AgentName: "Task Manager", - CreatedBy: "admin@xn--80akacl3adqr.xn--p1acf", + CreatedBy: "admin@syntrex.pro", AgentType: AgentSupervised, } if err := s.Register(agent); err != nil { diff --git a/internal/infrastructure/auth/handlers.go b/internal/infrastructure/auth/handlers.go index d5154d8..b90e963 100644 --- a/internal/infrastructure/auth/handlers.go +++ b/internal/infrastructure/auth/handlers.go @@ -50,10 +50,11 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { } accessToken, err := Sign(Claims{ - Sub: user.Email, - Role: user.Role, - TenantID: user.TenantID, - Exp: time.Now().Add(15 * time.Minute).Unix(), + Sub: user.Email, + Role: user.Role, + TenantID: user.TenantID, + TokenType: "access", + Exp: time.Now().Add(15 * time.Minute).Unix(), }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") @@ -61,10 +62,11 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { } refreshToken, err := Sign(Claims{ - Sub: user.Email, - Role: user.Role, - TenantID: user.TenantID, - Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), + Sub: user.Email, + Role: user.Role, + TenantID: user.TenantID, + TokenType: "refresh", + Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") @@ -101,6 +103,12 @@ func HandleRefresh(secret []byte) http.HandlerFunc { return } + // SEC-C5: Only accept refresh tokens for token renewal + if claims.TokenType != "refresh" { + writeAuthError(w, http.StatusUnauthorized, "invalid token type — refresh token required") + return + } + accessToken, err := NewAccessToken(claims.Sub, claims.Role, secret, 0) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") diff --git a/internal/infrastructure/auth/jwt.go b/internal/infrastructure/auth/jwt.go index 826524d..e41247b 100644 --- a/internal/infrastructure/auth/jwt.go +++ b/internal/infrastructure/auth/jwt.go @@ -16,19 +16,21 @@ import ( // Standard JWT errors. var ( - ErrInvalidToken = errors.New("auth: invalid token") - ErrExpiredToken = errors.New("auth: token expired") - ErrInvalidSecret = errors.New("auth: secret too short (min 32 bytes)") + ErrInvalidToken = errors.New("auth: invalid token") + ErrExpiredToken = errors.New("auth: token expired") + ErrInvalidSecret = errors.New("auth: secret too short (min 32 bytes)") + ErrWrongTokenType = errors.New("auth: wrong token type") ) // Claims represents JWT payload. type Claims struct { - Sub string `json:"sub"` // Subject (username or user ID) - Role string `json:"role"` // RBAC role: admin, operator, analyst, viewer - TenantID string `json:"tenant_id,omitempty"` // Multi-tenant isolation - Exp int64 `json:"exp"` // Expiration (Unix timestamp) - Iat int64 `json:"iat"` // Issued at - Iss string `json:"iss,omitempty"` // Issuer + Sub string `json:"sub"` // Subject (username or user ID) + Role string `json:"role"` // RBAC role: admin, operator, analyst, viewer + TenantID string `json:"tenant_id,omitempty"` // Multi-tenant isolation + TokenType string `json:"token_type,omitempty"` // "access" or "refresh" + Exp int64 `json:"exp"` // Expiration (Unix timestamp) + Iat int64 `json:"iat"` // Issued at + Iss string `json:"iss,omitempty"` // Issuer } // IsExpired returns true if the token has expired. @@ -101,9 +103,10 @@ func NewAccessToken(subject, role string, secret []byte, ttl time.Duration) (str ttl = 15 * time.Minute } return Sign(Claims{ - Sub: subject, - Role: role, - Exp: time.Now().Add(ttl).Unix(), + Sub: subject, + Role: role, + TokenType: "access", + Exp: time.Now().Add(ttl).Unix(), }, secret) } @@ -113,9 +116,10 @@ func NewRefreshToken(subject, role string, secret []byte, ttl time.Duration) (st ttl = 7 * 24 * time.Hour } return Sign(Claims{ - Sub: subject, - Role: role, - Exp: time.Now().Add(ttl).Unix(), + Sub: subject, + Role: role, + TokenType: "refresh", + Exp: time.Now().Add(ttl).Unix(), }, secret) } diff --git a/internal/infrastructure/auth/middleware.go b/internal/infrastructure/auth/middleware.go index 40308c0..78421a9 100644 --- a/internal/infrastructure/auth/middleware.go +++ b/internal/infrastructure/auth/middleware.go @@ -78,6 +78,18 @@ func (m *JWTMiddleware) Middleware(next http.Handler) http.Handler { return } + // SEC-C5: Reject refresh tokens used as access tokens. + // Only "access" tokens (or legacy tokens without type) can access protected routes. + if claims.TokenType == "refresh" { + slog.Warn("refresh token used as access token", + "sub", claims.Sub, + "path", r.URL.Path, + "remote", r.RemoteAddr, + ) + writeAuthError(w, http.StatusUnauthorized, "access token required — refresh tokens cannot be used for API access") + return + } + // Inject claims into context for downstream handlers. ctx := context.WithValue(r.Context(), claimsKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/internal/infrastructure/auth/tenant_handlers.go b/internal/infrastructure/auth/tenant_handlers.go index 7bd9236..68d7323 100644 --- a/internal/infrastructure/auth/tenant_handlers.go +++ b/internal/infrastructure/auth/tenant_handlers.go @@ -4,9 +4,14 @@ import ( "encoding/json" "log/slog" "net/http" + "os" + "regexp" "time" ) +// htmlTagRegex strips HTML/script tags from user input (M5 XSS prevention). +var htmlTagRegex = regexp.MustCompile(`<[^>]*>`) + // EmailSendFunc is a callback for sending verification emails. // Signature: func(toEmail, userName, code string) error type EmailSendFunc func(toEmail, userName, code string) error @@ -17,6 +22,12 @@ type EmailSendFunc func(toEmail, userName, code string) error // If emailFn is nil, verification code is returned in response (dev mode). func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []byte, emailFn EmailSendFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // SEC-M4: Server-side registration gate + if os.Getenv("SOC_REGISTRATION_OPEN") != "true" { + http.Error(w, `{"error":"registration is closed — contact admin for an invitation"}`, http.StatusForbidden) + return + } + var req struct { Email string `json:"email"` Password string `json:"password"` @@ -40,6 +51,10 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret [] req.Name = req.Email } + // SEC-M5: Strip HTML tags from user input to prevent stored XSS + req.Name = htmlTagRegex.ReplaceAllString(req.Name, "") + req.OrgName = htmlTagRegex.ReplaceAllString(req.OrgName, "") + // Create user first (admin of new tenant) user, err := userStore.CreateUser(req.Email, req.Name, req.Password, "admin") if err != nil { @@ -141,10 +156,11 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret // Issue JWT with tenant context accessToken, err := Sign(Claims{ - Sub: user.Email, - Role: user.Role, - TenantID: tenantID, - Exp: time.Now().Add(15 * time.Minute).Unix(), + Sub: user.Email, + Role: user.Role, + TenantID: tenantID, + TokenType: "access", + Exp: time.Now().Add(15 * time.Minute).Unix(), }, jwtSecret) if err != nil { http.Error(w, `{"error":"failed to issue token"}`, http.StatusInternalServerError) @@ -152,10 +168,11 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret } refreshToken, _ := Sign(Claims{ - Sub: user.Email, - Role: user.Role, - TenantID: tenantID, - Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), + Sub: user.Email, + Role: user.Role, + TenantID: tenantID, + TokenType: "refresh", + Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), }, jwtSecret) var tenant *Tenant diff --git a/internal/infrastructure/auth/users.go b/internal/infrastructure/auth/users.go index 979cc92..d0d4b3b 100644 --- a/internal/infrastructure/auth/users.go +++ b/internal/infrastructure/auth/users.go @@ -64,11 +64,11 @@ func NewUserStore(db ...*sql.DB) *UserStore { } // Ensure default admin exists - if _, err := s.GetByEmail("admin@xn--80akacl3adqr.xn--p1acf"); err != nil { + if _, err := s.GetByEmail("admin@syntrex.pro"); err != nil { hash, _ := bcrypt.GenerateFromPassword([]byte("syntrex-admin-2026"), bcrypt.DefaultCost) admin := &User{ ID: generateID("usr"), - Email: "admin@xn--80akacl3adqr.xn--p1acf", + Email: "admin@syntrex.pro", DisplayName: "Administrator", Role: "admin", Active: true, diff --git a/internal/infrastructure/sbom/sbom.go b/internal/infrastructure/sbom/sbom.go index 64ba8ed..0fe6eeb 100644 --- a/internal/infrastructure/sbom/sbom.go +++ b/internal/infrastructure/sbom/sbom.go @@ -101,7 +101,7 @@ func (g *Generator) GenerateSPDX() (*SPDXDocument, error) { DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", DocumentName: fmt.Sprintf("%s-%s", g.productName, g.version), - Namespace: fmt.Sprintf("https://sentinel.xn--80akacl3adqr.xn--p1acf/spdx/%s/%s", g.productName, g.version), + Namespace: fmt.Sprintf("https://sentinel.syntrex.pro/spdx/%s/%s", g.productName, g.version), CreationInfo: CreationInfo{ Created: time.Now().UTC().Format(time.RFC3339), Creators: []string{"Tool: sentinel-sbom-gen", "Organization: Syntrex"},