feat: login page says a locked instance is suspended instead of drawing the form
This commit is contained in:
@@ -68,17 +68,40 @@ func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
return InstanceForHost(c.Request.Host)
|
||||
}
|
||||
|
||||
// instanceBySlug is a variable so tests can stub the Mongo read.
|
||||
var instanceBySlug = services.GetInstanceBySlugIncludingLocked
|
||||
|
||||
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
|
||||
//
|
||||
// An instance Vantage HQ has locked under a dispute resolves to nothing, so
|
||||
// every caller refuses it without knowing locks exist. HostLocked is the one
|
||||
// place that may tell the two apart, for the login page's message.
|
||||
func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
slug := hostSlug(host)
|
||||
if slug == "" {
|
||||
inst := slugInstance(hostSlug(host))
|
||||
if inst == nil || inst.LockedAt != nil {
|
||||
return nil, false
|
||||
}
|
||||
if inst, hit := cachedInstanceFor(slug); hit {
|
||||
return inst, inst != nil
|
||||
}
|
||||
return inst, true
|
||||
}
|
||||
|
||||
inst, err := services.GetInstanceBySlug(slug)
|
||||
// HostLocked reports whether the host names an instance Vantage HQ has locked.
|
||||
// It shares InstanceForHost's cache, so a lock and an unlock are seen within
|
||||
// the same 60 seconds by both.
|
||||
func HostLocked(host string) bool {
|
||||
inst := slugInstance(hostSlug(host))
|
||||
return inst != nil && inst.LockedAt != nil
|
||||
}
|
||||
|
||||
// slugInstance reads the instance a slug names, locked or not, through the
|
||||
// cache.
|
||||
func slugInstance(slug string) *models.Instance {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
if inst, hit := cachedInstanceFor(slug); hit {
|
||||
return inst
|
||||
}
|
||||
inst, err := instanceBySlug(slug)
|
||||
if err != nil || inst == nil {
|
||||
// Negative entries are cached too. Without them an unknown but
|
||||
// well-formed host costs a Mongo query per anonymous request, which
|
||||
@@ -86,10 +109,10 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
// round trip is itself a timing oracle separating "no such instance"
|
||||
// from "instance exists, page does not".
|
||||
storeInstance(slug, nil)
|
||||
return nil, false
|
||||
return nil
|
||||
}
|
||||
storeInstance(slug, inst)
|
||||
return inst, true
|
||||
return inst
|
||||
}
|
||||
|
||||
// SoleInstance resolves the one instance of a deployment that has exactly one.
|
||||
@@ -99,7 +122,7 @@ func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
// more than one instance exists.
|
||||
func SoleInstance() (*models.Instance, bool) {
|
||||
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
|
||||
return inst, inst != nil
|
||||
return inst, inst != nil && inst.LockedAt == nil
|
||||
}
|
||||
n, err := services.CountInstances()
|
||||
if err != nil || n != 1 {
|
||||
@@ -112,7 +135,8 @@ func SoleInstance() (*models.Instance, bool) {
|
||||
return nil, false
|
||||
}
|
||||
storeInstance(soleInstanceCacheKey, inst)
|
||||
return inst, true
|
||||
// A locked sole instance is refused like any other locked instance.
|
||||
return inst, inst.LockedAt == nil
|
||||
}
|
||||
|
||||
func cachedInstanceFor(key string) (*models.Instance, bool) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
// stubSlugs replaces the slug lookup and empties the host cache for one test.
|
||||
func stubSlugs(t *testing.T, bySlug map[string]*models.Instance) {
|
||||
t.Helper()
|
||||
prev := instanceBySlug
|
||||
instanceBySlug = func(slug string) (*models.Instance, error) {
|
||||
if inst, ok := bySlug[slug]; ok {
|
||||
return inst, nil
|
||||
}
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
instanceCacheMu.Lock()
|
||||
instanceCache = map[string]cachedInstance{}
|
||||
instanceCacheMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
instanceBySlug = prev
|
||||
instanceCacheMu.Lock()
|
||||
instanceCache = map[string]cachedInstance{}
|
||||
instanceCacheMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestLockedHostResolvesToNothingButReportsLocked(t *testing.T) {
|
||||
at := time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC)
|
||||
stubSlugs(t, map[string]*models.Instance{
|
||||
"acme": {InstanceID: "i-acme", Slug: "acme", LockedAt: &at},
|
||||
"open": {InstanceID: "i-open", Slug: "open"},
|
||||
})
|
||||
|
||||
if _, ok := InstanceForHost("acme.vantage.example.com"); ok {
|
||||
t.Fatal("a locked instance must not resolve")
|
||||
}
|
||||
if !HostLocked("acme.vantage.example.com") {
|
||||
t.Fatal("a locked instance's host must report locked")
|
||||
}
|
||||
|
||||
if inst, ok := InstanceForHost("open.vantage.example.com"); !ok || inst.InstanceID != "i-open" {
|
||||
t.Fatalf("unlocked instance: got %v, %v", inst, ok)
|
||||
}
|
||||
if HostLocked("open.vantage.example.com") {
|
||||
t.Fatal("an unlocked instance is not locked")
|
||||
}
|
||||
|
||||
if HostLocked("missing.vantage.example.com") || HostLocked("example.com") {
|
||||
t.Fatal("a host naming no instance is not locked")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -31,10 +32,18 @@ func SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
//
|
||||
// Anything else is refused rather than guessed. Picking an instance on someone's
|
||||
// behalf is how you sign them into the wrong tenant.
|
||||
//
|
||||
// An instance Vantage HQ has locked answers ErrInstanceLocked on both paths.
|
||||
// Without the check on the fallback, a single-instance deployment whose host
|
||||
// resolver hides the locked instance fell through to "the sole instance" and
|
||||
// served its sign-in page anyway.
|
||||
func resolveLoginInstance(c *gin.Context) (string, error) {
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
if HostLocked(c.Request.Host) {
|
||||
return "", ErrInstanceLocked
|
||||
}
|
||||
n, err := services.CountInstances()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -48,9 +57,22 @@ func resolveLoginInstance(c *gin.Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if inst.LockedAt != nil {
|
||||
return "", ErrInstanceLocked
|
||||
}
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
|
||||
// ErrInstanceLocked is a sign-in to an instance Vantage HQ has locked under an
|
||||
// account dispute. The login page shows it as a message in place of the form.
|
||||
var ErrInstanceLocked = errors.New("access to this instance is suspended")
|
||||
|
||||
// loginLocked reports whether this request's sign-in target is locked.
|
||||
func loginLocked(c *gin.Context) bool {
|
||||
_, err := resolveLoginInstance(c)
|
||||
return errors.Is(err, ErrInstanceLocked)
|
||||
}
|
||||
|
||||
func HandleLocalLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
@@ -61,6 +83,10 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
instanceID, err := resolveLoginInstance(c)
|
||||
if errors.Is(err, ErrInstanceLocked) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -101,6 +127,10 @@ func HandleListPublicProviders(c *gin.Context) {
|
||||
out := []publicProvider{}
|
||||
|
||||
instanceID, err := resolveLoginInstance(c)
|
||||
if errors.Is(err, ErrInstanceLocked) {
|
||||
c.JSON(http.StatusOK, gin.H{"local_enabled": false, "providers": out, "locked": true})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// An unresolvable instance is not an error the login page can act on:
|
||||
// it still has to render a password form. Answer the safe shape.
|
||||
@@ -127,6 +157,12 @@ func HandleBootstrapStatus(c *gin.Context) {
|
||||
err error
|
||||
instName string
|
||||
)
|
||||
// Checked first: a locked instance must not fall through to the global
|
||||
// user count, which answers needs_setup false and draws the sign-in form.
|
||||
if loginLocked(c) {
|
||||
c.JSON(http.StatusOK, gin.H{"needs_setup": false, "locked": true})
|
||||
return
|
||||
}
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
n, err = services.CountInstanceUsers(inst.InstanceID)
|
||||
instName = inst.Name
|
||||
|
||||
@@ -88,6 +88,10 @@ func loadProvider(instanceID, providerID string) (*models.AuthProvider, string,
|
||||
func HandleSSOStart(c *gin.Context) {
|
||||
inst, ok := InstanceFromHost(c)
|
||||
if !ok {
|
||||
if HostLocked(c.Request.Host) {
|
||||
c.Redirect(http.StatusFound, "/login?error=instance_locked")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?error=unknown_host")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user