mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
245 lines
9.2 KiB
Go
245 lines
9.2 KiB
Go
package instancecheck
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
docsRegistration = "/development/accounts-and-access/#registration-modes"
|
|
docsSignIn = "/development/accounts-and-access/#sign-in-methods"
|
|
docsAdmins = "/development/accounts-and-access/#platform-admins"
|
|
docsFirstOwner = "/development/accounts-and-access/#first-owner"
|
|
docsInvitations = "/development/accounts-and-access/#invitations"
|
|
)
|
|
|
|
// setupTokenKey mirrors the unexported key in internal/app/bootstrap. The two
|
|
// must stay identical or the outstanding-setup-link check goes silent.
|
|
const setupTokenKey = "bootstrap:setup_token"
|
|
|
|
func accessChecks() []check {
|
|
return []check{
|
|
{id: "registration_mode", run: checkRegistrationMode},
|
|
{id: "no_sign_in_method", run: checkNoSignInMethod},
|
|
{id: "google_sign_in_incomplete", run: checkGoogleSignInIncomplete},
|
|
{id: "apple_sign_in_incomplete", run: checkAppleSignInIncomplete},
|
|
{id: "single_platform_admin", run: checkSinglePlatformAdmin},
|
|
{id: "bootstrap_password_still_set", run: checkBootstrapPasswordStillSet},
|
|
{id: "setup_link_outstanding", run: checkSetupLinkOutstanding},
|
|
{id: "expired_invitations", run: checkExpiredInvitations},
|
|
}
|
|
}
|
|
|
|
func checkRegistrationMode(ctx context.Context, d Deps, in Input) *Finding {
|
|
return result(CategoryAccess, SeverityInfo, "Registration mode",
|
|
fmt.Sprintf("Registration is %s. `invite_only` means nobody can create an account from the sign-up form; "+
|
|
"people join through an invitation from Settings > Members in the dashboard. "+
|
|
"`true` means signups are fully closed and invitations do not work either.", policy(d).Registration),
|
|
docsRegistration)
|
|
}
|
|
|
|
func checkNoSignInMethod(ctx context.Context, d Deps, in Input) *Finding {
|
|
if !policy(d).DisablePasswordLogin {
|
|
return nil
|
|
}
|
|
if env("OIDC_ISSUER_URL") != "" || runtimeOf(d).OIDCConfigured {
|
|
return nil
|
|
}
|
|
if googleSignInConfigured() || appleSignInConfigured() {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityError, "No way to sign in",
|
|
"Password login is disabled and no single sign-on provider is configured, so there is no way to sign in to this instance. "+
|
|
"Set DISABLE_PASSWORD_LOGIN=false or configure OIDC_ISSUER_URL.",
|
|
docsSignIn)
|
|
}
|
|
|
|
// googleSignInConfigured and appleSignInConfigured mirror what the backend
|
|
// actually requires to wire the provider. A client id on its own enables
|
|
// nothing, which is why "I set GOOGLE_CLIENT_ID and the button does not work"
|
|
// is the report this pair of checks exists to answer.
|
|
func googleSignInConfigured() bool {
|
|
return env("GOOGLE_CLIENT_ID") != "" && env("GOOGLE_CLIENT_SECRET") != "" && ssoRedirectConfigured("GOOGLE_REDIRECT_URI")
|
|
}
|
|
|
|
func appleSignInConfigured() bool {
|
|
return env("APPLE_APP_ID") != "" && env("APPLE_TEAM_ID") != "" && env("APPLE_KEY_ID") != "" &&
|
|
env("APPLE_KEY_SECRET") != "" && ssoRedirectConfigured("APPLE_REDIRECT_URI")
|
|
}
|
|
|
|
func ssoRedirectConfigured(key string) bool {
|
|
return env(key) != "" || env("API_PUBLIC_URL") != ""
|
|
}
|
|
|
|
func checkGoogleSignInIncomplete(ctx context.Context, d Deps, in Input) *Finding {
|
|
if env("GOOGLE_CLIENT_ID") == "" && env("GOOGLE_CLIENT_SECRET") == "" {
|
|
return nil
|
|
}
|
|
if missing := firstUnset("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"); missing != "" {
|
|
return result(CategoryAccess, SeverityError, "Sign in with Google is half configured",
|
|
fmt.Sprintf("%s is not set, so the Google button is not shown and the sign-in cannot complete. "+
|
|
"Both halves of the OAuth client are required.", missing),
|
|
docsSignIn)
|
|
}
|
|
if !ssoRedirectConfigured("GOOGLE_REDIRECT_URI") {
|
|
return result(CategoryAccess, SeverityError, "Sign in with Google has no redirect URL",
|
|
"The Google client is configured but there is no redirect URI: API_PUBLIC_URL is empty and GOOGLE_REDIRECT_URI is not set, "+
|
|
"so Sign in with Google is disabled. Set API_PUBLIC_URL to this backend's public base.",
|
|
docsSignIn)
|
|
}
|
|
return misdirectedSSORedirect("Google", "GOOGLE_REDIRECT_URI", "google")
|
|
}
|
|
|
|
func checkAppleSignInIncomplete(ctx context.Context, d Deps, in Input) *Finding {
|
|
keys := []string{"APPLE_APP_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_KEY_SECRET"}
|
|
if allUnset(keys...) {
|
|
return nil
|
|
}
|
|
if missing := firstUnset(keys...); missing != "" {
|
|
return result(CategoryAccess, SeverityError, "Sign in with Apple is half configured",
|
|
fmt.Sprintf("%s is not set, so the Apple button is not shown. Sign in with Apple needs the Services ID, "+
|
|
"the team id, and the key id and key together.", missing),
|
|
docsSignIn)
|
|
}
|
|
if !ssoRedirectConfigured("APPLE_REDIRECT_URI") {
|
|
return result(CategoryAccess, SeverityError, "Sign in with Apple has no redirect URL",
|
|
"The Apple credentials are set but there is no redirect URI: API_PUBLIC_URL is empty and APPLE_REDIRECT_URI is not set, "+
|
|
"so Sign in with Apple is disabled. Set API_PUBLIC_URL to this backend's public base.",
|
|
docsSignIn)
|
|
}
|
|
if redirect := ssoRedirectURI("APPLE_REDIRECT_URI", "apple"); !strings.HasPrefix(redirect, "https://") {
|
|
return result(CategoryAccess, SeverityError, "Sign in with Apple needs an HTTPS redirect URL",
|
|
fmt.Sprintf("Apple refuses a plain-http return URL, so Sign in with Apple is disabled with the redirect URI at %s. "+
|
|
"Put this backend behind HTTPS.", redirect),
|
|
docsSignIn)
|
|
}
|
|
return misdirectedSSORedirect("Apple", "APPLE_REDIRECT_URI", "apple")
|
|
}
|
|
|
|
// misdirectedSSORedirect catches the mistake that produces a valid OAuth client
|
|
// and a broken button: a redirect URI on the dashboard origin. The callback is
|
|
// served by the API, so the provider lands the browser on a dashboard route
|
|
// that does not exist.
|
|
func misdirectedSSORedirect(provider, key, slug string) *Finding {
|
|
raw := env(key)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" || strings.Contains(u.Path, "/v1/auth/") {
|
|
return nil
|
|
}
|
|
if !strings.EqualFold(u.Host, hostOf(appURL())) {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityWarning, fmt.Sprintf("The %s redirect URI points at the dashboard", provider),
|
|
fmt.Sprintf("%s is %s, which is the dashboard. The callback is served by the API, so the sign-in ends on a page that does not exist. "+
|
|
"Use %s and register that at the provider.", key, raw, ssoRedirectURI("", slug)),
|
|
docsSignIn)
|
|
}
|
|
|
|
// ssoRedirectURI mirrors the backend's derivation so a finding can name the
|
|
// exact value to register at the provider.
|
|
func ssoRedirectURI(key, slug string) string {
|
|
if key != "" {
|
|
if v := env(key); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
if base := strings.TrimRight(env("API_PUBLIC_URL"), "/"); base != "" {
|
|
return base + "/v1/auth/" + slug + "/callback"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func allUnset(keys ...string) bool {
|
|
for _, key := range keys {
|
|
if env(key) != "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func firstUnset(keys ...string) string {
|
|
for _, key := range keys {
|
|
if env(key) == "" {
|
|
return key
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func checkSinglePlatformAdmin(ctx context.Context, d Deps, in Input) *Finding {
|
|
if d.DB == nil {
|
|
return nil
|
|
}
|
|
var count int
|
|
var email string
|
|
err := d.DB.QueryRow(ctx,
|
|
`SELECT count(*), COALESCE(min(email), '') FROM users WHERE admin_permissions > 0`).Scan(&count, &email)
|
|
if err != nil || count != 1 {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityInfo, "Only one platform admin",
|
|
fmt.Sprintf("This instance has one platform admin (%s). If you lose access to that account there is no way "+
|
|
"to grant admin from inside the product. Add a second admin from Instance > Admins.", email),
|
|
docsAdmins)
|
|
}
|
|
|
|
func checkBootstrapPasswordStillSet(ctx context.Context, d Deps, in Input) *Finding {
|
|
if env("WARMBLY_BOOTSTRAP_PASSWORD") == "" || d.DB == nil {
|
|
return nil
|
|
}
|
|
count, err := userCount(ctx, d)
|
|
if err != nil || count == 0 {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityWarning, "Bootstrap password is still set",
|
|
"WARMBLY_BOOTSTRAP_PASSWORD is still set in this deployment's environment. "+
|
|
"It is only read while the users table is empty, so it now does nothing except leave a plaintext password "+
|
|
"in your process environment. Remove it.",
|
|
docsFirstOwner)
|
|
}
|
|
|
|
func checkSetupLinkOutstanding(ctx context.Context, d Deps, in Input) *Finding {
|
|
if d.DB == nil || d.Cache == nil {
|
|
return nil
|
|
}
|
|
count, err := userCount(ctx, d)
|
|
if err != nil || count != 0 {
|
|
return nil
|
|
}
|
|
ttl, terr := d.Cache.TTL(ctx, setupTokenKey).Result()
|
|
if terr != nil || ttl <= 0 {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityInfo, "A setup link is outstanding",
|
|
fmt.Sprintf("This instance has no accounts yet. A single-use setup link is live for %s; "+
|
|
"find it with `make claim` or in the backend log.", humanizeDuration(ttl)),
|
|
docsFirstOwner)
|
|
}
|
|
|
|
func checkExpiredInvitations(ctx context.Context, d Deps, in Input) *Finding {
|
|
if d.DB == nil {
|
|
return nil
|
|
}
|
|
var count int
|
|
if err := d.DB.QueryRow(ctx,
|
|
`SELECT count(*) FROM organization_invitations WHERE expires_at < NOW()`).Scan(&count); err != nil || count == 0 {
|
|
return nil
|
|
}
|
|
return result(CategoryAccess, SeverityInfo, "Expired invitations",
|
|
fmt.Sprintf("%d invitations have expired and are no longer visible in the dashboard, "+
|
|
"but still hold their email address. Re-inviting the same address now replaces the expired row.", count),
|
|
docsInvitations)
|
|
}
|
|
|
|
func userCount(ctx context.Context, d Deps) (int, error) {
|
|
var count int
|
|
err := d.DB.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&count)
|
|
return count, err
|
|
}
|