feat: login page says a locked instance is suspended instead of drawing the form
This commit is contained in:
@@ -1002,9 +1002,13 @@ plane, each of which this codebase enforces:
|
||||
- **Disputes lock and purge instances.** HQ writes `instances.locked_at` when
|
||||
an account is disputed and `instances.purge_after` only once the dispute
|
||||
fails. `services.InstanceLocked` (60s cache) refuses sessions, API tokens
|
||||
and agents on a locked instance, and `GetInstanceBySlug` hides it from the
|
||||
host resolver. The check in `auth.Middleware` is explicit because the host
|
||||
guard only runs when a host resolves. `ReapTerminatedInstances` purges once
|
||||
and agents on a locked instance, and `InstanceForHost` and `SoleInstance`
|
||||
resolve it to nothing. The check in `auth.Middleware` is explicit because the
|
||||
host guard only runs when a host resolves. `HostLocked` is the one place that
|
||||
tells "locked" from "unknown", so the login page can say access is suspended
|
||||
rather than draw a form; `resolveLoginInstance` refuses a locked instance on
|
||||
the single-instance fallback too, which otherwise served its sign-in page.
|
||||
A locked instance's public status page stays an ordinary 404. `ReapTerminatedInstances` purges once
|
||||
both fields are present and `purge_after` has passed, independent of
|
||||
`FREE_INSTANCE_REAP_AFTER`, and `ReapFreeInstances` skips locked instances
|
||||
so a restore finds them intact.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,21 @@ func GetInstanceBySlug(slug string) (*models.Instance, error) {
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// GetInstanceBySlugIncludingLocked is GetInstanceBySlug without the lock
|
||||
// filter. Only the host resolver calls it, so it can tell "no such instance"
|
||||
// from "instance locked by Vantage HQ" and let the login page say which; it
|
||||
// still refuses a locked instance itself.
|
||||
func GetInstanceBySlugIncludingLocked(slug string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func ListInstanceIDs() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+25
-3
@@ -22,6 +22,7 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
session_failed: "Could not start your session. Please try again.",
|
||||
state_failed: "Could not start sign-in. Please try again.",
|
||||
unknown_host: "This address does not name a known instance.",
|
||||
instance_locked: "Access to this instance is suspended.",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -33,16 +34,24 @@ export default function LoginPage() {
|
||||
// usable rather than an empty card.
|
||||
const [localEnabled, setLocalEnabled] = useState(true);
|
||||
const [ssoError, setSsoError] = useState("");
|
||||
// Vantage HQ has locked this instance under an account dispute. The page
|
||||
// says so instead of drawing a form that cannot sign anyone in.
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = new URLSearchParams(window.location.search).get("error");
|
||||
if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again.");
|
||||
if (code === "instance_locked") setLocked(true);
|
||||
else if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again.");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await auth.bootstrapStatus();
|
||||
if (s.locked) {
|
||||
setLocked(true);
|
||||
return;
|
||||
}
|
||||
if (s.needs_setup) {
|
||||
window.location.href = "/setup";
|
||||
return;
|
||||
@@ -51,6 +60,10 @@ export default function LoginPage() {
|
||||
} catch {}
|
||||
try {
|
||||
const p = await auth.providers();
|
||||
if (p.locked) {
|
||||
setLocked(true);
|
||||
return;
|
||||
}
|
||||
setProviders(p.providers);
|
||||
setLocalEnabled(p.local_enabled);
|
||||
} catch {}
|
||||
@@ -86,11 +99,19 @@ export default function LoginPage() {
|
||||
<div className="relative w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<Logo className="h-11 w-auto text-logo" />
|
||||
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Sign in to Vantage</h1>
|
||||
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">{locked ? "Instance unavailable" : "Sign in to Vantage"}</h1>
|
||||
{/* A keyed label, not a heading: site/'s .tag treatment. */}
|
||||
<p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>
|
||||
{!locked && <p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>}
|
||||
</div>
|
||||
|
||||
{locked ? (
|
||||
<Card>
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">Access to this instance is suspended.</div>
|
||||
<p className="mt-4 text-sm text-text-secondary">
|
||||
Nobody can sign in, and its servers are not being managed, until this is resolved. If you manage this account, check your email from Vantage for details.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
{ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}
|
||||
|
||||
@@ -155,6 +176,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -443,6 +443,8 @@ export interface MeResponse {
|
||||
export interface BootstrapStatus {
|
||||
needs_setup: boolean;
|
||||
instance_name?: string;
|
||||
/** Vantage HQ has locked this instance; the login page shows a message instead of the form. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
@@ -479,6 +481,7 @@ export interface PublicProvider {
|
||||
export interface ProvidersResponse {
|
||||
local_enabled: boolean;
|
||||
providers: PublicProvider[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
|
||||
Reference in New Issue
Block a user