Junie: security cleanups.

This commit is contained in:
2025-06-06 13:50:37 -07:00
parent 95d96732d2
commit 23c7a65799
13 changed files with 812 additions and 119 deletions

View File

@@ -1,14 +1,18 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/data"
"github.com/Masterminds/squirrel"
"github.com/oklog/ulid/v2"
)
@@ -63,48 +67,51 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Check password only first
// Check password only
if !user.CheckPassword(&req.Login) {
s.sendError(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// If TOTP is enabled and a code was provided, verify it
if user.HasTOTP() {
if req.Login.TOTPCode == "" {
// TOTP is enabled but no code was provided
// Return a special response indicating TOTP is required
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
if err := json.NewEncoder(w).Encode(ErrorResponse{
Error: "TOTP code required",
}); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
return
}
// Validate the TOTP code
valid, validErr := user.ValidateTOTPCode(req.Login.TOTPCode)
if validErr != nil || !valid {
s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized)
return
}
}
// Password is correct, create a token regardless of TOTP status
token, expires, err := s.createToken(user.ID)
if err != nil {
s.Logger.Printf("Token creation error: %v", err)
// Log the security event
details := map[string]string{
"reason": "Token creation error",
"error": err.Error(),
}
s.LogSecurityEvent(r, "login_attempt", user.ID, user.User, false, details)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
// If user has TOTP enabled, include this information in the response
totpEnabled := user.HasTOTP()
// Log successful login
details := map[string]string{
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
"totp_enabled": fmt.Sprintf("%t", totpEnabled),
}
s.LogSecurityEvent(r, "login_success", user.ID, user.User, true, details)
// Include TOTP status in the response
response := struct {
TokenResponse
TOTPEnabled bool `json:"totp_enabled"`
}{
TokenResponse: TokenResponse{
Token: token,
Expires: expires,
},
TOTPEnabled: totpEnabled,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(TokenResponse{
Token: token,
Expires: expires,
}); err != nil {
if err := json.NewEncoder(w).Encode(response); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
}
@@ -147,20 +154,61 @@ func (s *Server) handleTokenLogin(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) sendError(w http.ResponseWriter, message string, status int) {
// Log the detailed error message for debugging
s.Logger.Printf("Error response: %s (Status: %d)", message, status)
// Create a generic error message based on status code
publicMessage := "An error occurred processing your request"
errorCode := "E" + string(status)
// Customize public messages for common status codes
// but don't leak specific details about the error
switch status {
case http.StatusBadRequest:
publicMessage = "Invalid request format"
case http.StatusUnauthorized:
publicMessage = "Authentication required"
case http.StatusForbidden:
publicMessage = "Insufficient permissions"
case http.StatusNotFound:
publicMessage = "Resource not found"
case http.StatusTooManyRequests:
publicMessage = "Rate limit exceeded"
case http.StatusInternalServerError:
publicMessage = "Internal server error"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(ErrorResponse{Error: message}); err != nil {
response := struct {
Error string `json:"error"`
ErrorCode string `json:"error_code"`
}{
Error: publicMessage,
ErrorCode: errorCode,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.Logger.Printf("Error encoding error response: %v", err)
}
}
func (s *Server) getUserByUsername(username string) (*data.User, error) {
query := `SELECT id, created, user, password, salt, totp_secret FROM users WHERE user = ?`
row := s.DB.QueryRow(query, username)
// Use squirrel to build the query safely
query, args, err := squirrel.Select("id", "created", "user", "password", "salt", "totp_secret").
From("users").
Where(squirrel.Eq{"user": username}).
ToSql()
if err != nil {
return nil, err
}
row := s.DB.QueryRow(query, args...)
user := &data.User{}
var totpSecret sql.NullString
err := row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret)
err = row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret)
if err != nil {
return nil, err
}
@@ -170,12 +218,17 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) {
user.TOTPSecret = totpSecret.String
}
rolesQuery := `
SELECT r.role FROM roles r
JOIN user_roles ur ON r.id = ur.rid
WHERE ur.uid = ?
`
rows, err := s.DB.Query(rolesQuery, user.ID)
// Use squirrel to build the roles query safely
rolesQuery, rolesArgs, err := squirrel.Select("r.role").
From("roles r").
Join("user_roles ur ON r.id = ur.rid").
Where(squirrel.Eq{"ur.uid": user.ID}).
ToSql()
if err != nil {
return nil, err
}
rows, err := s.DB.Query(rolesQuery, rolesArgs...)
if err != nil {
return nil, err
}
@@ -195,12 +248,28 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) {
}
func (s *Server) createToken(userID string) (string, int64, error) {
token := ulid.Make().String()
// Generate 16 bytes of random data
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return "", 0, err
}
// Hex encode the random bytes to get a 32-character string
token := hex.EncodeToString(tokenBytes)
expires := time.Now().Add(24 * time.Hour).Unix()
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
tokenID := ulid.Make().String()
_, err := s.DB.Exec(query, tokenID, userID, token, expires)
// Use squirrel to build the insert query safely
query, args, err := squirrel.Insert("tokens").
Columns("id", "uid", "token", "expires").
Values(tokenID, userID, token, expires).
ToSql()
if err != nil {
return "", 0, err
}
_, err = s.DB.Exec(query, args...)
if err != nil {
return "", 0, err
}
@@ -209,14 +278,22 @@ func (s *Server) createToken(userID string) (string, int64, error) {
}
func (s *Server) verifyToken(username, token string) (string, error) {
query := `
SELECT t.uid, t.expires FROM tokens t
JOIN users u ON t.uid = u.id
WHERE u.user = ? AND t.token = ?
`
// Use squirrel to build the query safely
query, args, err := squirrel.Select("t.uid", "t.expires").
From("tokens t").
Join("users u ON t.uid = u.id").
Where(squirrel.And{
squirrel.Eq{"u.user": username},
squirrel.Eq{"t.token": token},
}).
ToSql()
if err != nil {
return "", err
}
var userID string
var expires int64
err := s.DB.QueryRow(query, username, token).Scan(&userID, &expires)
err = s.DB.QueryRow(query, args...).Scan(&userID, &expires)
if err != nil {
return "", err
}
@@ -230,21 +307,38 @@ func (s *Server) verifyToken(username, token string) (string, error) {
func (s *Server) renewToken(username, token string) (int64, error) {
// First, verify the token exists and get the token ID
query := `
SELECT t.id FROM tokens t
JOIN users u ON t.uid = u.id
WHERE u.user = ? AND t.token = ?
`
// Use squirrel to build the query safely
query, args, err := squirrel.Select("t.id").
From("tokens t").
Join("users u ON t.uid = u.id").
Where(squirrel.And{
squirrel.Eq{"u.user": username},
squirrel.Eq{"t.token": token},
}).
ToSql()
if err != nil {
return 0, err
}
var tokenID string
err := s.DB.QueryRow(query, username, token).Scan(&tokenID)
err = s.DB.QueryRow(query, args...).Scan(&tokenID)
if err != nil {
return 0, err
}
// Update the token's expiry time
expires := time.Now().Add(24 * time.Hour).Unix()
updateQuery := `UPDATE tokens SET expires = ? WHERE id = ?`
_, err = s.DB.Exec(updateQuery, expires, tokenID)
// Use squirrel to build the update query safely
updateQuery, updateArgs, err := squirrel.Update("tokens").
Set("expires", expires).
Where(squirrel.Eq{"id": tokenID}).
ToSql()
if err != nil {
return 0, err
}
_, err = s.DB.Exec(updateQuery, updateArgs...)
if err != nil {
return 0, err
}
@@ -277,6 +371,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
// Check if TOTP is enabled for the user
if !user.HasTOTP() {
// Log the security event
details := map[string]string{
"reason": "TOTP not enabled for user",
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "TOTP not enabled for user", http.StatusBadRequest)
return
}
@@ -284,6 +383,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
// Validate the TOTP code
valid, validErr := user.ValidateTOTPCode(req.TOTPCode)
if validErr != nil || !valid {
// Log the security event
details := map[string]string{
"reason": "Invalid TOTP code",
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized)
return
}
@@ -292,10 +396,22 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
token, expires, err := s.createToken(user.ID)
if err != nil {
s.Logger.Printf("Token creation error: %v", err)
// Log the security event
details := map[string]string{
"reason": "Token creation error",
"error": err.Error(),
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
// Log successful TOTP verification
details := map[string]string{
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
}
s.LogSecurityEvent(r, "totp_verification_success", user.ID, user.User, true, details)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(TokenResponse{
@@ -356,8 +472,18 @@ func (s *Server) handleDatabaseCredentials(w http.ResponseWriter, r *http.Reques
}
// Retrieve database credentials
query := `SELECT id, host, port, name, user, password FROM database LIMIT 1`
row := s.DB.QueryRow(query)
// Use squirrel to build the query safely
query, args, err := squirrel.Select("id", "host", "port", "name", "user", "password").
From("database").
Limit(1).
ToSql()
if err != nil {
s.Logger.Printf("Query building error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
row := s.DB.QueryRow(query, args...)
var id string
var creds DatabaseCredentials