Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ configuration summary as JSON. The status surface is for feed configuration and
observability only; host findings and customer-facing vulnerability reporting
remain in CT Ops.

The first time the status page is opened, CT-CVE redirects to `/signup` so the
initial Admin user can be created. After that user exists, `/signup` is no
longer available and unauthenticated users are redirected to `/login`. Sessions
are stored server-side, protected with an `HttpOnly` same-site cookie, and
configuration changes require a per-session CSRF token.

The status page can enable or disable the NVD and CISA KEV sources, change
their feed endpoints, adjust the NVD request delay, and set or clear the NVD
API key. Saved source settings are stored in the CT-CVE database and are applied
Expand Down
117 changes: 117 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package auth

import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"time"

"golang.org/x/crypto/bcrypt"
)

const (
RoleAdmin = "admin"
minPasswordSize = 12
tokenBytes = 32
bcryptCost = 12
)

var (
ErrUserExists = errors.New("user already exists")
ErrUserNotFound = errors.New("user not found")
ErrSessionNotFound = errors.New("session not found")
)

type User struct {
ID int64
Username string
PasswordHash string
Role string
CreatedAt time.Time
}

type NewUser struct {
Username string
PasswordHash string
Role string
}

type Session struct {
TokenHash string
UserID int64
CSRFToken string
ExpiresAt time.Time
}

type NewSession struct {
TokenHash string
UserID int64
CSRFToken string
ExpiresAt time.Time
}

func NormalizeUsername(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

func ValidateUsername(value string) error {
if len(value) < 3 || len(value) > 64 {
return errors.New("username must be between 3 and 64 characters")
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
continue
}
return errors.New("username may only contain lowercase letters, numbers, hyphens, underscores, and periods")
}
return nil
}

func ValidatePassword(value string) error {
if len(value) < minPasswordSize {
return errors.New("password must be at least 12 characters")
}
if len(value) > 1024 {
return errors.New("password must be 1024 characters or fewer")
}
return nil
}

func HashPassword(password string) (string, error) {
if err := ValidatePassword(password); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}

func CheckPassword(passwordHash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) == nil
}

func NewSessionToken() (string, error) {
return randomToken()
}

func NewCSRFToken() (string, error) {
return randomToken()
}

func HashSessionToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

func randomToken() (string, error) {
bytes := make([]byte, tokenBytes)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
Loading
Loading