117 lines
3.3 KiB
Go
117 lines
3.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/veylant/ia-gateway/internal/apierror"
|
|
"github.com/veylant/ia-gateway/internal/middleware"
|
|
)
|
|
|
|
// LoginHandler handles POST /v1/auth/login (public — no JWT required).
|
|
type LoginHandler struct {
|
|
db *sql.DB
|
|
jwtSecret string
|
|
jwtTTLHours int
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewLoginHandler creates a LoginHandler.
|
|
func NewLoginHandler(db *sql.DB, jwtSecret string, jwtTTLHours int, logger *zap.Logger) *LoginHandler {
|
|
return &LoginHandler{db: db, jwtSecret: jwtSecret, jwtTTLHours: jwtTTLHours, logger: logger}
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type loginResponse struct {
|
|
Token string `json:"token"`
|
|
User userInfo `json:"user"`
|
|
}
|
|
|
|
type userInfo struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
TenantID string `json:"tenant_id"`
|
|
Department string `json:"department,omitempty"`
|
|
}
|
|
|
|
// ServeHTTP handles the login request.
|
|
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if h.db == nil {
|
|
apierror.WriteError(w, &apierror.APIError{
|
|
Type: "not_implemented",
|
|
Message: "database not configured",
|
|
HTTPStatus: http.StatusNotImplemented,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req loginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON"))
|
|
return
|
|
}
|
|
if req.Email == "" || req.Password == "" {
|
|
apierror.WriteError(w, apierror.NewBadRequestError("email and password are required"))
|
|
return
|
|
}
|
|
|
|
// Look up user by email (cross-tenant — each email is unique per tenant, but
|
|
// for V1 single-tenant we look globally).
|
|
var u userInfo
|
|
var passwordHash string
|
|
err := h.db.QueryRowContext(r.Context(),
|
|
`SELECT id, tenant_id, email, COALESCE(name,''), role, COALESCE(department,''), password_hash
|
|
FROM users
|
|
WHERE email = $1 AND is_active = TRUE
|
|
LIMIT 1`,
|
|
req.Email,
|
|
).Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Role, &u.Department, &passwordHash)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// Use same error to avoid user enumeration
|
|
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.logger.Error("login db query failed", zap.Error(err))
|
|
apierror.WriteError(w, apierror.NewUpstreamError("login failed"))
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
|
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
|
|
return
|
|
}
|
|
|
|
claims := &middleware.UserClaims{
|
|
UserID: u.ID,
|
|
TenantID: u.TenantID,
|
|
Email: u.Email,
|
|
Roles: []string{u.Role},
|
|
Department: u.Department,
|
|
}
|
|
|
|
token, err := GenerateToken(claims, u.Name, h.jwtSecret, h.jwtTTLHours)
|
|
if err != nil {
|
|
h.logger.Error("token generation failed", zap.Error(err))
|
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to generate token"))
|
|
return
|
|
}
|
|
|
|
h.logger.Info("user logged in", zap.String("email", u.Email), zap.String("tenant_id", u.TenantID))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(loginResponse{Token: token, User: u})
|
|
}
|