The /web endpoints have never had authentication. The console keeps its
login record in per-tab sessionStorage and sends no Authorization header,
so every endpoint under /v1/web read `tenantid` off the query string and
believed it — one number in a URL reached another merchant's orders,
stock, staff and takings. `createposuser` under /v1/web/tenants minted
till credentials on the strength of an unauthenticated request, which the
route file already flagged in as many words.
Closed the same way posauth.go closed it for the terminals, in the same
order: the caller holds a token this server signed, and the tenant they
name is the tenant inside that token.
- utils/webtoken.go same HMAC construction as the POS token, 12h TTL,
a `w1.` prefix so the two kinds cannot verify as
each other
- middleware/webauth.go verifies the token, pins the tenant, and checks
a named branch belongs to it; reads the tenant from
the query, the body, and inside a JSON array, since
createdeliveries posts one
- login now issues the token; the console sends it as Bearer
Platform access rides on issuperadmin and nothing else. Not the role —
app_roles calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
every shop owner, so a role test would promote every merchant on the
platform. Not a zero tenant either, or a user row with the field unset
becomes the one session that reads everything. Both near-misses have
tests.
WEB_AUTH_REQUIRED defaults to off. The console in production does not
send a token yet, and enforcing before it does would lock every merchant
out of a working product. A token that IS sent is always verified, and
one naming the wrong tenant is always refused; the flag only decides what
happens to a request carrying none. This should be a short-lived state.
Still trusting the caller: partnerid, customerid and appuserid, which
some list endpoints also scope on. Noted in the middleware header.
25 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
8.6 KiB
Go
327 lines
8.6 KiB
Go
package controllers
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
"nearle/services"
|
|
"nearle/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// attachWebSession hands a signed-in console user their session token.
|
|
//
|
|
// Added to the login response rather than served from a second endpoint, so the
|
|
// console receives it on the call it already makes and nothing changes about
|
|
// when or how it signs in.
|
|
//
|
|
// The claims come from the user's own record, which is the whole point: until
|
|
// now the console asserted its tenant on every request and was believed, and
|
|
// sealing it under a signature here is what makes `middleware.WebAuth` able to
|
|
// refuse a request naming somebody else's.
|
|
//
|
|
// `Issuperadmin` is copied across as the ONLY source of cross-tenant access.
|
|
// Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding
|
|
// wrote 1 for every shop owner, so trusting the role would promote every
|
|
// merchant on the platform.
|
|
//
|
|
// A failure to mint is logged and swallowed, deliberately, while
|
|
// WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must
|
|
// still be able to sign in, or shipping this takes the console down everywhere
|
|
// the secret is missing. Once enforcement is on, no token means no session —
|
|
// which is then the correct and loud failure.
|
|
//
|
|
// The parameter is the underlying map type rather than `fiber.Map`, because the
|
|
// two login paths do not agree on which fiber that is: `AppLogin` returns the
|
|
// v1 package's `Map` and `TenantWebLogin` the v2 one. Both are
|
|
// `map[string]any`, so taking that accepts either without dragging the
|
|
// old import into this file.
|
|
func attachWebSession(resp map[string]any, info models.TenantUserInfo) {
|
|
token, expires, err := utils.MintWebToken(utils.WebClaims{
|
|
Userid: info.Userid,
|
|
Tenantid: info.Tenantid,
|
|
Locationid: info.Locationid,
|
|
Roleid: info.Roleid,
|
|
Configid: info.Configid,
|
|
Superadmin: info.Issuperadmin,
|
|
}, time.Now())
|
|
if err != nil {
|
|
log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err)
|
|
return
|
|
}
|
|
resp["token"] = token
|
|
resp["tokenexpiresat"] = expires.Unix()
|
|
}
|
|
|
|
type UserController struct {
|
|
userService services.UserService
|
|
}
|
|
|
|
func NewUserController(userService services.UserService) *UserController {
|
|
return &UserController{userService: userService}
|
|
}
|
|
|
|
func (ctl *UserController) GetAllUsers(c *fiber.Ctx) error {
|
|
roleID, _ := strconv.Atoi(c.Query("roleid", "0"))
|
|
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
|
|
pageno, _ := strconv.Atoi(c.Query("pageno", "1"))
|
|
pagesize, _ := strconv.Atoi(c.Query("pagesize", "10"))
|
|
keyword := c.Query("keyword", "")
|
|
|
|
if tenantID == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "tenantid is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
users, err := ctl.userService.GetAllUsers(roleID, tenantID, pageno, pagesize, keyword)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": users,
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) GetUserInfo(c *fiber.Ctx) error {
|
|
uid, err := strconv.Atoi(c.Query("userid"))
|
|
if err != nil || uid <= 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid userid",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
user, err := ctl.userService.GetUserByID(uid)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": user,
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) Login(c *fiber.Ctx) error {
|
|
var user models.User
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid request body",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
info, err := ctl.userService.Login(user)
|
|
if err != nil {
|
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusConflict,
|
|
"message": "User not found",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": info,
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) TenantLogin(c *fiber.Ctx) error {
|
|
var user models.User
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
info, err := ctl.userService.TenantLogin(user)
|
|
if err != nil {
|
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusConflict,
|
|
"message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "Success",
|
|
"status": true,
|
|
"details": info,
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) UpdateStaff(c *fiber.Ctx) error {
|
|
var user models.User
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
// This endpoint also doubles as the password-setup/reset call (userid +
|
|
// password only, everything else left zero so GORM's Updates skips it) —
|
|
// guard the one field that has no validation anywhere else on this path.
|
|
if pw := strings.TrimSpace(user.Password); pw != "" && len(pw) < 6 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusBadRequest,
|
|
"message": "Password must be at least 6 characters",
|
|
})
|
|
}
|
|
|
|
if err := ctl.userService.UpdateStaff(user); err != nil {
|
|
return c.JSON(fiber.Map{
|
|
"status": false,
|
|
"code": http.StatusConflict,
|
|
"message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"status": true,
|
|
"code": http.StatusAccepted,
|
|
"message": "User update successful",
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
|
var user models.User
|
|
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"code": 400,
|
|
"status": false,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
info, resp, err := ctl.userService.AppLogin(user)
|
|
if err != nil {
|
|
// Use resp.Code if present, fallback to 409
|
|
code := http.StatusConflict
|
|
if v, ok := resp["code"].(int); ok {
|
|
code = v
|
|
}
|
|
return c.Status(code).JSON(resp)
|
|
}
|
|
|
|
attachWebSession(resp, info)
|
|
|
|
// ✅ Always return resp
|
|
return c.Status(http.StatusOK).JSON(resp)
|
|
}
|
|
|
|
func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
|
var user models.User
|
|
|
|
// Parse request body
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"status": false,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
// Call service
|
|
info, err := ctl.userService.CreateUser(user)
|
|
if err != nil {
|
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
|
"code": http.StatusConflict,
|
|
"status": false,
|
|
"message": "Failed",
|
|
})
|
|
}
|
|
|
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
|
"code": http.StatusCreated,
|
|
"status": true,
|
|
"message": "Success",
|
|
"details": info,
|
|
})
|
|
}
|
|
|
|
func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error {
|
|
var user models.User
|
|
if err := c.BodyParser(&user); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"status": false,
|
|
"code": fiber.StatusBadRequest,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
info, resp := ctl.userService.TenantWebLogin(user)
|
|
|
|
// Ensure the response map contains the correct status code
|
|
code, ok := resp["code"].(int)
|
|
if !ok {
|
|
code = fiber.StatusInternalServerError
|
|
}
|
|
|
|
// Include tenant user info if login successful (code 200)
|
|
if code == fiber.StatusOK {
|
|
resp["details"] = info
|
|
attachWebSession(resp, info)
|
|
}
|
|
|
|
return c.Status(code).JSON(resp)
|
|
}
|
|
|
|
func (ctl *UserController) DeleteUser(c *fiber.Ctx) error {
|
|
uid, err := strconv.Atoi(c.Query("userid"))
|
|
if err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Invalid user ID",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if err := ctl.userService.DeleteUser(uid); err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"message": "User successfully deleted",
|
|
"status": true,
|
|
})
|
|
}
|
|
|