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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down
175 changes: 175 additions & 0 deletions internal/api/handlers/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: 2026 ArcheBase
//
// SPDX-License-Identifier: MulanPSL-2.0

package handlers

import (
"database/sql"
"net/http"
"strconv"
"strings"

"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"

"archebase.com/keystone-edge/internal/auth"
"archebase.com/keystone-edge/internal/config"
)

// AuthHandler provides authentication-related HTTP handlers.
type AuthHandler struct {
db *sqlx.DB
cfg *config.AuthConfig
}

// NewAuthHandler constructs an AuthHandler with required dependencies.
func NewAuthHandler(db *sqlx.DB, cfg *config.AuthConfig) *AuthHandler {
return &AuthHandler{db: db, cfg: cfg}
}

// CollectorLoginRequest is the request body for collector login.
type CollectorLoginRequest struct {
OperatorID string `json:"operator_id" binding:"required"`
Password string `json:"password" binding:"required"` // #nosec G117 -- request DTO intentionally contains password
}

// CollectorLoginResponse is the response body returned after a successful collector login.
type CollectorLoginResponse struct {
AccessToken string `json:"access_token"` // #nosec G117 -- response DTO intentionally returns access token
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Collector struct {
ID string `json:"id"`
OperatorID string `json:"operator_id"`
Name string `json:"name"`
} `json:"collector"`
}

type collectorAuthRow struct {
ID int64 `db:"id"`
Name string `db:"name"`
OperatorID string `db:"operator_id"`
PasswordHash sql.NullString `db:"password_hash"`
}

// RegisterRoutes registers auth endpoints under the provided router group.
func (h *AuthHandler) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/auth/login", h.LoginCollector)
r.POST("/auth/logout", h.Logout)
r.GET("/auth/me", h.Me)
}

// LoginCollector authenticates data collector and returns JWT access token.
func (h *AuthHandler) LoginCollector(c *gin.Context) {
var req CollectorLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}

req.OperatorID = strings.TrimSpace(req.OperatorID)
if req.OperatorID == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "operator_id and password are required"})
return
}

var row collectorAuthRow
err := h.db.Get(&row, `
SELECT id, name, operator_id, password_hash
FROM data_collectors
WHERE operator_id = ? AND deleted_at IS NULL
LIMIT 1
`, req.OperatorID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}

if !row.PasswordHash.Valid || strings.TrimSpace(row.PasswordHash.String) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}

if err := bcrypt.CompareHashAndPassword([]byte(row.PasswordHash.String), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}

_, _ = h.db.Exec("UPDATE data_collectors SET last_login_at = NOW() WHERE id = ?", row.ID)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Database update error is silently ignored

The last_login_at update failure is silently swallowed. While this is a non-critical operation, silently ignoring errors can mask underlying database issues.

Suggested change
_, _ = h.db.Exec("UPDATE data_collectors SET last_login_at = NOW() WHERE id = ?", row.ID)
if _, err := h.db.Exec("UPDATE data_collectors SET last_login_at = NOW() WHERE id = ?", row.ID); err != nil {
logger.Printf("[Auth] Failed to update last_login_at for collector %d: %v", row.ID, err)
}

At minimum, log the error for observability.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just ignore that


claims := auth.NewCollectorClaims(row.ID, row.OperatorID)
token, err := auth.GenerateToken(claims, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}

var resp CollectorLoginResponse
resp.AccessToken = token
resp.TokenType = "Bearer"
resp.ExpiresIn = h.cfg.JWTExpiryHours * 3600
resp.Collector.ID = strconv.FormatInt(row.ID, 10)
resp.Collector.OperatorID = row.OperatorID
resp.Collector.Name = row.Name

c.JSON(http.StatusOK, resp)
}

// Logout ends the current session. In this MVP it is a no-op on the server side.
func (h *AuthHandler) Logout(c *gin.Context) {
// MVP logout: client drops token. Keep endpoint for symmetry.
c.JSON(http.StatusOK, gin.H{"ok": true})
}

// Me returns the current authenticated collector identity.
func (h *AuthHandler) Me(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if strings.TrimSpace(authHeader) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}

parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"})
return
}

claims, err := auth.ParseToken(parts[1], h.cfg)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}

var row struct {
ID int64 `db:"id"`
Name string `db:"name"`
OperatorID string `db:"operator_id"`
}
if err := h.db.Get(&row, `
SELECT id, name, operator_id
FROM data_collectors
WHERE id = ? AND deleted_at IS NULL
LIMIT 1
`, claims.CollectorID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "collector not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}

c.JSON(http.StatusOK, gin.H{
"collector_id": claims.CollectorID,
"operator_id": row.OperatorID,
"name": row.Name,
"role": claims.Role,
})
}
77 changes: 47 additions & 30 deletions internal/api/handlers/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type BatchListItem struct {
Notes string `json:"notes,omitempty" db:"notes"`
Status string `json:"status" db:"status"`
EpisodeCount int `json:"episode_count" db:"episode_count"`
TaskCount int `json:"task_count" db:"task_count"`
StartedAt string `json:"started_at,omitempty"`
EndedAt string `json:"ended_at,omitempty"`
Metadata any `json:"metadata,omitempty"`
Expand All @@ -78,6 +79,7 @@ type batchRow struct {
Notes sql.NullString `db:"notes"`
Status string `db:"status"`
EpisodeCount int `db:"episode_count"`
TaskCount int `db:"task_count"`
StartedAt sql.NullTime `db:"started_at"`
EndedAt sql.NullTime `db:"ended_at"`
Metadata sql.NullString `db:"metadata"`
Expand Down Expand Up @@ -134,6 +136,7 @@ func batchListItemFromRow(r batchRow) BatchListItem {
Notes: notes,
Status: r.Status,
EpisodeCount: r.EpisodeCount,
TaskCount: r.TaskCount,
StartedAt: startedAt,
EndedAt: endedAt,
Metadata: parseNullableJSON(r.Metadata),
Expand Down Expand Up @@ -219,22 +222,29 @@ func (h *BatchHandler) ListBatches(c *gin.Context) {

query := fmt.Sprintf(`
SELECT
id,
batch_id,
order_id,
workstation_id,
name,
notes,
status,
episode_count,
started_at,
ended_at,
CAST(metadata AS CHAR) AS metadata,
created_at,
updated_at
FROM batches
b.id,
b.batch_id,
b.order_id,
b.workstation_id,
b.name,
b.notes,
b.status,
b.episode_count,
COALESCE(tc.task_count, 0) AS task_count,
b.started_at,
b.ended_at,
CAST(b.metadata AS CHAR) AS metadata,
b.created_at,
b.updated_at
FROM batches b
LEFT JOIN (
SELECT batch_id, COUNT(*) AS task_count
FROM tasks
WHERE deleted_at IS NULL
GROUP BY batch_id
) tc ON tc.batch_id = b.id
WHERE %s
ORDER BY id DESC
ORDER BY b.id DESC
LIMIT ? OFFSET ?
`, whereClause)

Expand Down Expand Up @@ -282,21 +292,28 @@ func (h *BatchHandler) GetBatch(c *gin.Context) {

query := `
SELECT
id,
batch_id,
order_id,
workstation_id,
name,
notes,
status,
episode_count,
started_at,
ended_at,
CAST(metadata AS CHAR) AS metadata,
created_at,
updated_at
FROM batches
WHERE id = ? AND deleted_at IS NULL
b.id,
b.batch_id,
b.order_id,
b.workstation_id,
b.name,
b.notes,
b.status,
b.episode_count,
COALESCE(tc.task_count, 0) AS task_count,
b.started_at,
b.ended_at,
CAST(b.metadata AS CHAR) AS metadata,
b.created_at,
b.updated_at
FROM batches b
LEFT JOIN (
SELECT batch_id, COUNT(*) AS task_count
FROM tasks
WHERE deleted_at IS NULL
GROUP BY batch_id
) tc ON tc.batch_id = b.id
WHERE b.id = ? AND b.deleted_at IS NULL
`

var r batchRow
Expand Down
37 changes: 36 additions & 1 deletion internal/api/handlers/data_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"

"archebase.com/keystone-edge/internal/logger"
)
Expand Down Expand Up @@ -55,6 +56,7 @@ type CreateDataCollectorRequest struct {
OperatorID string `json:"operator_id"`
Email string `json:"email,omitempty"`
Certification string `json:"certification,omitempty"`
Password string `json:"password,omitempty"` // #nosec G117 -- request DTO may include password for initial set
Metadata interface{} `json:"metadata,omitempty"`
}

Expand Down Expand Up @@ -209,6 +211,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) {
req.OperatorID = strings.TrimSpace(req.OperatorID)
req.Email = strings.TrimSpace(req.Email)
req.Certification = strings.TrimSpace(req.Certification)
req.Password = strings.TrimSpace(req.Password)

if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
Expand Down Expand Up @@ -257,20 +260,35 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) {
metadataStr = sql.NullString{String: string(metadataJSON), Valid: true}
}

var passwordHash sql.NullString
password := req.Password
if password == "" {
password = "123456"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Hardcoded default password "123456" is a severe security vulnerability

Using a predictable default password allows attackers to easily gain unauthorized access to any data collector account created without an explicit password. This violates security best practices.

Recommendations:

  1. Require password to be explicitly provided during creation
  2. Or generate a secure random password and return it in the response
  3. At minimum, use a cryptographically secure random default that's unique per account
Suggested change
password = "123456"
password = generateSecureRandomPassword()

Alternatively, require password and remove the default entirely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just ignore that.

}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Printf("[DC] Failed to hash password: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create data collector"})
return
}
passwordHash = sql.NullString{String: string(hash), Valid: true}

result, err := h.db.Exec(
`INSERT INTO data_collectors (
name,
operator_id,
email,
password_hash,
certification,
status,
metadata,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
req.Name,
req.OperatorID,
emailStr,
passwordHash,
certStr,
"active",
metadataStr,
Expand Down Expand Up @@ -373,6 +391,7 @@ type UpdateDataCollectorRequest struct {
Email *string `json:"email,omitempty"`
Certification *string `json:"certification,omitempty"`
Status *string `json:"status,omitempty"`
Password *string `json:"password,omitempty"` // #nosec G117 -- request DTO may include password update
Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"`
}

Expand Down Expand Up @@ -465,6 +484,22 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) {
args = append(args, certStr)
}

if req.Password != nil {
pw := strings.TrimSpace(*req.Password)
if pw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "password cannot be empty"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
logger.Printf("[DC] Failed to hash password: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update data collector"})
return
}
updates = append(updates, "password_hash = ?")
args = append(args, sql.NullString{String: string(hash), Valid: true})
}

if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if !validStatuses[status] {
Expand Down
Loading
Loading