mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 00:01:37 +00:00
Merge remote-tracking branch 'origin/main' into feature/mailbox-fair-use-allowance
This commit is contained in:
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
|
||||
// RevokeOwnAPIKey revokes the key the request was made with.
|
||||
//
|
||||
// Deliberately outside the API_KEYS scope gate: a credential must always be
|
||||
// able to end itself. Requiring a privilege to sign out means a read-only key
|
||||
// on a laptop someone is handing back stays live, which is the opposite of
|
||||
// what a `warmbly auth logout` promises.
|
||||
func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
|
||||
keyID := middleware.GetAPIKeyID(c)
|
||||
if keyID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
|
||||
return
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
|
||||
reason := c.Query("reason")
|
||||
if reason == "" {
|
||||
reason = "Revoked by the credential itself"
|
||||
}
|
||||
if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
|
||||
// ListAPIPermissions lists all available API permissions
|
||||
// GET /api-keys/permissions
|
||||
func (h *Handler) ListAPIPermissions(c *gin.Context) {
|
||||
|
||||
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
|
||||
// DocsURL is where to send someone whose signup was refused by deployment
|
||||
// policy rather than by anything they did wrong.
|
||||
DocsURL string `json:"docs_url"`
|
||||
|
||||
// WebsocketURL is the realtime gateway. Served here because a developer
|
||||
// client (the CLI's event stream, an SDK) has no other way to find the
|
||||
// socket on a self-hosted instance. Empty when the instance runs no
|
||||
// realtime service.
|
||||
WebsocketURL string `json:"websocket_url,omitempty"`
|
||||
|
||||
// AppURL is the dashboard origin, the same one every emailed link is built
|
||||
// from. A client that wants to send someone to a page (the CLI's `browse`,
|
||||
// a chat integration) cannot derive it: on a self-hosted instance the host
|
||||
// layout is whatever the operator chose.
|
||||
AppURL string `json:"app_url,omitempty"`
|
||||
}
|
||||
|
||||
// accountsDocsURL is the page every registration refusal points at.
|
||||
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
|
||||
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
|
||||
InvitesRequired: registration == config.RegistrationInviteOnly,
|
||||
DocsURL: accountsDocsURL,
|
||||
WebsocketURL: config.WebsocketURL(),
|
||||
AppURL: config.AppBaseURL(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
|
||||
// what the CLI calls; the session half is the browser approval screen.
|
||||
|
||||
func (h *Handler) cliAuthReady(c *gin.Context) bool {
|
||||
if h.CLIAuthService == nil {
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CLIAuthStart opens a handshake for a CLI that holds no key yet.
|
||||
func (h *Handler) CLIAuthStart(c *gin.Context) {
|
||||
if !h.cliAuthReady(c) {
|
||||
return
|
||||
}
|
||||
var req models.CLIAuthStartRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
||||
return
|
||||
}
|
||||
res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, res)
|
||||
}
|
||||
|
||||
// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
|
||||
// handed out exactly once, on the poll that follows approval.
|
||||
func (h *Handler) CLIAuthPoll(c *gin.Context) {
|
||||
if !h.cliAuthReady(c) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
|
||||
return
|
||||
}
|
||||
res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// CLIAuthDescribeCode shows the approving member what they are authorizing.
|
||||
func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
|
||||
if !h.cliAuthReady(c) {
|
||||
return
|
||||
}
|
||||
code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, code)
|
||||
}
|
||||
|
||||
// CLIAuthApproveCode mints the key into the workspace named in the body, not
|
||||
// the session's, because a member with several workspaces picks on the screen.
|
||||
func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
|
||||
if !h.cliAuthReady(c) {
|
||||
return
|
||||
}
|
||||
userID, err := middleware.GetUserUUID(c)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
var req models.CLIAuthApproveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
||||
return
|
||||
}
|
||||
orgID, perr := uuid.Parse(req.OrganizationID)
|
||||
if perr != nil {
|
||||
if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
|
||||
orgID = *sessionOrg
|
||||
} else {
|
||||
errx.JSON(c, errx.ErrNoOrganization)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
// Logged against the org that was picked on screen, which is not always the
|
||||
// session's, so this cannot go through auditOrg.
|
||||
h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
|
||||
c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
|
||||
c.JSON(http.StatusOK, code)
|
||||
}
|
||||
|
||||
// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
|
||||
// created, and a denial is not a change to the workspace.
|
||||
func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
|
||||
if !h.cliAuthReady(c) {
|
||||
return
|
||||
}
|
||||
if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/behavior"
|
||||
"github.com/warmbly/warmbly/internal/app/bootstrap"
|
||||
"github.com/warmbly/warmbly/internal/app/campaign"
|
||||
"github.com/warmbly/warmbly/internal/app/cliauth"
|
||||
"github.com/warmbly/warmbly/internal/app/cloudlink"
|
||||
"github.com/warmbly/warmbly/internal/app/compose"
|
||||
"github.com/warmbly/warmbly/internal/app/contact"
|
||||
@@ -312,6 +313,9 @@ type Handler struct {
|
||||
PoolLinkService poollink.Service
|
||||
CloudLinkService cloudlink.Service
|
||||
|
||||
// Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
|
||||
CLIAuthService cliauth.Service
|
||||
|
||||
// Infrastructure liveness probes for the admin System Status page.
|
||||
// Wired in cmd/backend/main.go where the concrete clients live.
|
||||
SystemChecker *sysstatus.Checker
|
||||
|
||||
@@ -16,8 +16,27 @@ const (
|
||||
// password, and far below what credential stuffing needs.
|
||||
authIPWindow = 15 * time.Minute
|
||||
authIPDefaultLimit = 60
|
||||
|
||||
// The CLI sign-in handshake gets its own budget, on its own key.
|
||||
//
|
||||
// It cannot share the auth one: `warmbly auth login` polls every
|
||||
// CLIAuthPollIntervalSeconds for up to CLIAuthCodeTTLMinutes, which is
|
||||
// around 200 requests for a single sign-in. On the shared budget that
|
||||
// exhausts the allowance in three minutes, and then blocks the person's
|
||||
// actual login from the same address for the rest of the window. The
|
||||
// allowance below covers two concurrent sign-ins from one NAT with slack.
|
||||
cliAuthIPWindow = 15 * time.Minute
|
||||
cliAuthIPDefaultLimit = 500
|
||||
)
|
||||
|
||||
// CLIAuthIPRateLimitMiddleware throttles the public CLI sign-in handshake per
|
||||
// source IP, on a key of its own so a long poll cannot lock the same address
|
||||
// out of signing in through the browser.
|
||||
func (h *Handler) CLIAuthIPRateLimitMiddleware() gin.HandlerFunc {
|
||||
return h.ipRateLimiter("cli_auth_ip:", cliAuthIPDefaultLimit, "CLI_AUTH_IP_RATE_LIMIT", cliAuthIPWindow,
|
||||
"Too many CLI sign-in requests from this address. Try again later.")
|
||||
}
|
||||
|
||||
// AuthIPRateLimitMiddleware throttles the public /auth group per source IP.
|
||||
//
|
||||
// This is the only limiter those routes have. RateLimitMiddleware keys on the
|
||||
@@ -29,8 +48,15 @@ const (
|
||||
// Fails open on a cache error, deliberately: a Redis blip must not lock every
|
||||
// user out of their own instance.
|
||||
func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
|
||||
limit := authIPDefaultLimit
|
||||
if v := os.Getenv("AUTH_IP_RATE_LIMIT"); v != "" {
|
||||
return h.ipRateLimiter("auth_ip:", authIPDefaultLimit, "AUTH_IP_RATE_LIMIT", authIPWindow,
|
||||
"Too many authentication attempts from this address. Try again later.")
|
||||
}
|
||||
|
||||
// ipRateLimiter is the shared fixed-window limiter behind both. Each caller
|
||||
// brings its own Redis key prefix, so budgets never bleed into each other.
|
||||
func (h *Handler) ipRateLimiter(prefix string, defaultLimit int, env string, window time.Duration, message string) gin.HandlerFunc {
|
||||
limit := defaultLimit
|
||||
if v := os.Getenv(env); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
@@ -53,21 +79,38 @@ func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
key := "auth_ip:" + ip
|
||||
key := prefix + ip
|
||||
n, err := h.Cache.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// A counter with no TTL never resets, so the address it belongs to
|
||||
// stays blocked forever once it passes the limit. That is a worse
|
||||
// outcome than not counting at all, so a failed EXPIRE drops the key
|
||||
// and lets the request through, matching how the rest of this
|
||||
// middleware handles a cache it cannot trust.
|
||||
if n == 1 {
|
||||
_ = h.Cache.Expire(c.Request.Context(), key, authIPWindow).Err()
|
||||
if err := h.Cache.Expire(c.Request.Context(), key, window).Err(); err != nil {
|
||||
_ = h.Cache.Del(c.Request.Context(), key).Err()
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
} else if n > int64(limit) {
|
||||
// Repair a key that lost its expiry some other way (an older
|
||||
// build, a restore, an eviction between the INCR and the EXPIRE
|
||||
// above). Only on the reject path, which is rare, so it costs a
|
||||
// round trip nobody feels.
|
||||
if ttl, terr := h.Cache.TTL(c.Request.Context(), key).Result(); terr == nil && ttl < 0 {
|
||||
_ = h.Cache.Expire(c.Request.Context(), key, window).Err()
|
||||
}
|
||||
}
|
||||
|
||||
if n > int64(limit) {
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(authIPWindow.Seconds())))
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(window.Seconds())))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many authentication attempts from this address. Try again later.",
|
||||
"message": message,
|
||||
"code": "rate_limit_exceeded",
|
||||
})
|
||||
c.Abort()
|
||||
|
||||
@@ -240,6 +240,18 @@ func Run(
|
||||
poolLinkPublic.POST("/poll", h.PoolLinkPoll)
|
||||
}
|
||||
|
||||
// `warmbly auth login`. Unauthenticated by nature (the CLI has no key yet),
|
||||
// so it is throttled per source IP, but on its OWN budget: one sign-in
|
||||
// polls around 200 times, which would exhaust the auth allowance and then
|
||||
// lock the same address out of the browser login for the rest of the
|
||||
// window.
|
||||
cliAuthPublic := v1.Group("/auth/cli")
|
||||
cliAuthPublic.Use(m.CLIAuthIPRateLimitMiddleware())
|
||||
{
|
||||
cliAuthPublic.POST("/code", h.CLIAuthStart)
|
||||
cliAuthPublic.POST("/poll", h.CLIAuthPoll)
|
||||
}
|
||||
|
||||
auth := v1.Group("/auth")
|
||||
// Every unauthenticated auth route shares one per-IP budget. Nothing
|
||||
// throttled these before: RateLimitMiddleware is keyed on the user id and
|
||||
@@ -754,6 +766,11 @@ func Run(
|
||||
// API key management. JWT users need PermManageAPIKeys; API keys
|
||||
// need the APIPermAPIKeys self-service bit. This lets an integration
|
||||
// rotate its own keys without going through the dashboard.
|
||||
// Self-revocation, outside the API_KEYS gate below on purpose: any
|
||||
// valid key may end itself, which is what makes signing a machine
|
||||
// out actually end its access.
|
||||
protected.DELETE("/api-keys/self", m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite), h.RevokeOwnAPIKey)
|
||||
|
||||
apiKeys := protected.Group("/api-keys")
|
||||
apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys))
|
||||
apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite))
|
||||
@@ -1223,6 +1240,18 @@ func Run(
|
||||
poolLink.GET("/instances", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkListInstances)
|
||||
poolLink.DELETE("/instances/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkRevokeInstance)
|
||||
}
|
||||
|
||||
// Browser half of `warmbly auth login`: a member reviews the code
|
||||
// and approves it into one of their workspaces. Session-only, like
|
||||
// the pool link approval, because approving mints a credential and
|
||||
// an API key must not be able to mint another CLI's key.
|
||||
cliAuth := jwtOnly.Group("/auth/cli")
|
||||
cliAuth.Use(m.RateLimitMiddleware(models.RateLimitWrite))
|
||||
{
|
||||
cliAuth.GET("/codes/:code", h.CLIAuthDescribeCode)
|
||||
cliAuth.POST("/codes/:code/approve", h.CLIAuthApproveCode)
|
||||
cliAuth.POST("/codes/:code/deny", h.CLIAuthDenyCode)
|
||||
}
|
||||
// The linked instance's own surface, authenticated by its token.
|
||||
poolLinkInstance := base.Group("/pool-link/instance")
|
||||
poolLinkInstance.Use(m.PoolLinkAuthMiddleware())
|
||||
|
||||
Reference in New Issue
Block a user