-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): implement JWT authentication for data collectors #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
90b6598
e523331
72c9771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
| 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, | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||
| ) | ||||||
|
|
@@ -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"` | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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"}) | ||||||
|
|
@@ -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" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Suggested change
Alternatively, require password and remove the default entirely.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
|
|
@@ -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"` | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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] { | ||||||
|
|
||||||
There was a problem hiding this comment.
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_atupdate failure is silently swallowed. While this is a non-critical operation, silently ignoring errors can mask underlying database issues.At minimum, log the error for observability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just ignore that