3611a8607d
* feat(rbac): add multi-use share-link invitations for invite_only mode When the auth.registration_mode toggle flips to invite_only, the public /auth/register endpoint returns 403 — but until now there was no channel for unregistered users to actually join a tenant. The existing invitation table required a registered invitee, so new emails could not enter the system at all. This change introduces a share-link model: - Owner generates a multi-use registration link with a per-tenant role - Recipient opens the link, registers with their own email, and is added to the tenant on the same request - Token is stored in plaintext (not hashed) so the management UI can re-display the URL on demand without a "copy now or revoke" trap. Threat model is bounded by 7-day TTL, revocability, and the fact that all the link grants is membership in one tenant. - accepted_count tracks how many users joined through the link so the Owner can tell whether a link is fresh or has already been spread Per-user invitations (registered email -> in-app inbox accept) are unchanged. Share-link rows use empty invitee_user_id as discriminator; the partial unique index on (tenant_id, invitee_user_id) was relaxed to skip empty values so multiple share links can coexist per tenant. Frontend reuses Login.vue for /register?token=xxx instead of a parallel page, and adds icon-only revoke / remove actions plus a distinct "active" status tag for share-link rows. Migration 000054 adds the token column, the accepted_count column, and the relaxed pending unique index in a single step. * feat(auth): update invitation token handling and add rate limiting - Refactor the invitation token retrieval to use POST instead of GET, enhancing security by preventing the plaintext token from appearing in logs and browser history. - Update the API endpoint from `/auth/invitations/:token` to `/auth/invitations/lookup` to reflect the new method. - Introduce a rate limiter for unauthenticated share-link endpoints to mitigate brute-force attacks and abuse, ensuring a maximum of 30 requests per minute per IP. - Adjust the registration flow to maintain user experience while securing the invitation process.
106 lines
3.6 KiB
Go
106 lines
3.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Tencent/WeKnora/internal/config"
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
// frontendBaseURLFor returns the externally-visible SPA origin used to
|
|
// compose absolute share-link URLs. Lookup order:
|
|
// 1. config.FrontendBaseURL (set in YAML or via ${FRONTEND_BASE_URL})
|
|
// 2. FRONTEND_BASE_URL env at request time (so an operator can roll
|
|
// the value out without restarting the server)
|
|
// 3. empty — handler emits a host-relative URL ("/register?token=…")
|
|
// and the SPA resolves it against the current origin.
|
|
//
|
|
// Trailing slashes are stripped so callers can append "/register?…"
|
|
// cleanly.
|
|
func frontendBaseURLFor(cfg *config.Config) string {
|
|
candidate := ""
|
|
if cfg != nil {
|
|
candidate = strings.TrimSpace(cfg.FrontendBaseURL)
|
|
}
|
|
if candidate == "" {
|
|
candidate = strings.TrimSpace(os.Getenv("FRONTEND_BASE_URL"))
|
|
}
|
|
return strings.TrimRight(candidate, "/")
|
|
}
|
|
|
|
// buildInviteRegisterURL composes the registration URL Owners hand
|
|
// to invitees. Plaintext token is URL-safe by construction (base64url)
|
|
// so no extra escaping is required.
|
|
func buildInviteRegisterURL(cfg *config.Config, plainToken string) string {
|
|
if plainToken == "" {
|
|
return ""
|
|
}
|
|
return frontendBaseURLFor(cfg) + "/register?token=" + plainToken
|
|
}
|
|
|
|
// createInviteLinkRequest is the body for POST /tenants/:id/invite-links.
|
|
// Only role + optional message — share-link rows have no specific
|
|
// invitee, so the Owner just picks "what role does the holder get".
|
|
type createInviteLinkRequest struct {
|
|
Role types.TenantRole `json:"role" binding:"required"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// CreateInviteLink godoc
|
|
// @Summary 生成共享邀请链接
|
|
// @Description 生成一条多次使用的共享邀请链接:谁拿到链接谁就能注册并加入当前租户。
|
|
// @Description 链接持续有效,直到过期或被撤销。
|
|
// @Tags 租户邀请
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "租户 ID"
|
|
// @Param request body createInviteLinkRequest true "共享链接配置"
|
|
// @Success 201 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /tenants/{id}/invite-links [post]
|
|
func (h *TenantInvitationHandler) CreateInviteLink(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
tenantID, ok := parseTenantIDFromPath(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req createInviteLinkRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("invalid request body").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
if !req.Role.IsValid() {
|
|
c.Error(apperrors.NewValidationError("role must be one of owner/admin/contributor/viewer"))
|
|
return
|
|
}
|
|
|
|
caller, _ := types.UserIDFromContext(ctx)
|
|
var invitedBy *string
|
|
if caller != "" && !types.IsSyntheticUserID(caller) {
|
|
invitedBy = &caller
|
|
}
|
|
|
|
inv, _, err := h.invitationService.CreateShareLink(ctx, tenantID, req.Role, invitedBy, req.Message)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "CreateShareLink failed: tenant=%d err=%v", tenantID, err)
|
|
c.Error(apperrors.NewInternalServerError("failed to create share link").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
usersByID := map[string]*types.User{}
|
|
if invitedBy != nil {
|
|
if iu, lookupErr := h.userService.GetUserByID(ctx, *invitedBy); lookupErr == nil && iu != nil {
|
|
usersByID[iu.ID] = iu
|
|
}
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": h.projectInvitationWithLink(inv, usersByID, nil),
|
|
})
|
|
}
|