Add explict allow deny commands.
This commit is contained in:
@@ -132,7 +132,10 @@ Docker (always present on a dokku host), and nginx built with
|
||||
|
||||
5. Note the client ID and client secret.
|
||||
|
||||
### 2. Configure the plugin (one time)
|
||||
### 2. Configure the plugin
|
||||
|
||||
`configure` is re-runnable: every flag it takes is persisted and can be
|
||||
changed later by passing it again.
|
||||
|
||||
```bash
|
||||
dokku google-auth:configure \
|
||||
@@ -151,12 +154,60 @@ Other flags (all optional, all persisted):
|
||||
|---|---|---|
|
||||
| `--allow-domain <d>` | allow any verified `*@d` account (repeatable; replaces the stored list) | — |
|
||||
| `--allow-email <e>` | allow a specific address, e.g. an outside collaborator (repeatable) | — |
|
||||
| `--deny-email <e>` | block a specific address even if the allow lists cover it (repeatable; replaces the stored list) | — |
|
||||
| `--clear-deny-emails` | empty the deny list | — |
|
||||
| `--session-ttl <dur>` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` |
|
||||
| `--cookie-name <n>` | session cookie name | `_google_auth` |
|
||||
| `--port <p>` | host port (127.0.0.1 only) for the auth service | `2999` |
|
||||
| `--regenerate-cookie-secret` | rotate the session encryption key (signs everyone out) | — |
|
||||
|
||||
### 3. Protect apps
|
||||
These flags **replace** the list they name, which suits initial setup. For
|
||||
one-at-a-time changes afterwards, see the next section.
|
||||
|
||||
### 3. Decide who is allowed in
|
||||
|
||||
`--allow-domain` and `--allow-email` together form the **allowlist**, and at
|
||||
least one entry is required. Any account matching neither is rejected — there
|
||||
is no "allow everyone" mode. So to limit access to a specific list of people,
|
||||
use only addresses and no domain:
|
||||
|
||||
```bash
|
||||
dokku google-auth:allow greyson@signal.org alice@signal.org
|
||||
```
|
||||
|
||||
The **deny list** is checked first and wins over both allow rules, which is
|
||||
how you cut off one person without narrowing the whole domain:
|
||||
|
||||
```bash
|
||||
dokku google-auth:allow signal.org # everyone at signal.org…
|
||||
dokku google-auth:deny former@signal.org # …except this account
|
||||
```
|
||||
|
||||
These four commands each change one entry at a time and restart the auth
|
||||
service for you:
|
||||
|
||||
```bash
|
||||
dokku google-auth:allow # show the allow list
|
||||
dokku google-auth:allow signal.org # a domain (any verified account there)
|
||||
dokku google-auth:allow guest@partner.com # one address
|
||||
dokku google-auth:unallow guest@partner.com # remove either kind
|
||||
|
||||
dokku google-auth:deny # show the deny list
|
||||
dokku google-auth:deny former@signal.org
|
||||
dokku google-auth:undeny former@signal.org
|
||||
```
|
||||
|
||||
Changes take effect on the affected user's **next request**: session cookies
|
||||
are re-checked against the current lists rather than trusted until they
|
||||
expire, so denying (or unallowing) someone with a live session ends it. To
|
||||
sign out everyone at once instead, use
|
||||
`configure --regenerate-cookie-secret`.
|
||||
|
||||
`unallow` refuses to remove the last allow entry, since an empty allowlist
|
||||
locks everyone out of every enabled app. `dokku google-auth:report` shows
|
||||
both lists as they currently stand.
|
||||
|
||||
### 4. Protect apps
|
||||
|
||||
```bash
|
||||
dokku google-auth:enable my-app
|
||||
@@ -167,7 +218,7 @@ dokku google-auth:enable other-app
|
||||
That's it. Visit the app in a browser — you'll be bounced through Google and
|
||||
back.
|
||||
|
||||
### 4. Exclude paths (optional, per app)
|
||||
### 5. Exclude paths (optional, per app)
|
||||
|
||||
```bash
|
||||
# Path prefix — everything under it is open:
|
||||
@@ -187,8 +238,10 @@ protect them yourself (API key, HMAC signature, etc.).
|
||||
### Day-to-day commands
|
||||
|
||||
```bash
|
||||
dokku google-auth:report # global + per-app status
|
||||
dokku google-auth:report # global + per-app status, incl. both access lists
|
||||
dokku google-auth:report my-app # one app
|
||||
dokku google-auth:allow alice@signal.org # let someone in
|
||||
dokku google-auth:deny former@signal.org # cut someone off
|
||||
dokku google-auth:disable my-app # turn SSO off for an app
|
||||
dokku google-auth:logs -t # follow auth service logs (sign-ins, denials)
|
||||
dokku google-auth:restart # restart the auth service
|
||||
|
||||
@@ -6,7 +6,11 @@ case "$1" in
|
||||
help | google-auth:help)
|
||||
help_content() {
|
||||
cat <<help_content
|
||||
google-auth:configure [options], Configure the shared Google OAuth service (run once)
|
||||
google-auth:configure [options], Set Google credentials and who may sign in (re-run any time to change)
|
||||
google-auth:allow [<domain|address>...], Let a domain or address sign in (no arguments lists the allow list)
|
||||
google-auth:unallow <domain|address...>, Remove a domain or address from the allow list
|
||||
google-auth:deny [<address>...], Block an address even if the allow list covers it (no arguments lists the deny list)
|
||||
google-auth:undeny <address...>, Remove an address from the deny list
|
||||
google-auth:enable <app>, Require Google sign-in for all requests to <app>
|
||||
google-auth:disable <app>, Remove Google sign-in from <app>
|
||||
google-auth:exclude <app> <pattern...>, Exempt path prefixes (/path) or regexes (re:^/x) from sign-in
|
||||
@@ -26,6 +30,27 @@ help_content
|
||||
echo ''
|
||||
echo 'Commands:'
|
||||
help_content | sort | column -c2 -t -s,
|
||||
echo ''
|
||||
echo 'google-auth:configure options (all persisted; re-run to change any of them):'
|
||||
echo ''
|
||||
echo ' --client-id <id> Google OAuth client id'
|
||||
echo ' --client-secret <secret> Google OAuth client secret'
|
||||
echo ' --auth-host <host> the one host registered as a redirect URI with Google'
|
||||
echo ' --allow-domain <domain> allow any verified account at <domain> (repeatable)'
|
||||
echo ' --allow-email <address> allow one specific address (repeatable)'
|
||||
echo ' --deny-email <address> block one address, even if a rule above allows it (repeatable)'
|
||||
echo ' --clear-deny-emails empty the deny list'
|
||||
echo ' --session-ttl <duration> how long a sign-in lasts (default 24h)'
|
||||
echo ' --cookie-name <name> session cookie name (default _google_auth)'
|
||||
echo ' --port <port> 127.0.0.1 port for the auth service (default 2999)'
|
||||
echo ' --regenerate-cookie-secret rotate the session key (signs everyone out)'
|
||||
echo ''
|
||||
echo 'The allow rules are a strict allowlist: an account that matches none of them'
|
||||
echo 'is rejected, and at least one rule is required. The deny list wins over both.'
|
||||
echo ''
|
||||
echo 'These flags REPLACE the list they name, so they suit initial setup. To add or'
|
||||
echo 'remove one person afterwards, use google-auth:allow / :unallow / :deny /'
|
||||
echo ':undeny, which change one entry at a time and restart the service for you.'
|
||||
else
|
||||
help_content
|
||||
fi
|
||||
|
||||
@@ -73,6 +73,65 @@ fn-ga-global-set-list() {
|
||||
chmod 600 "$file"
|
||||
}
|
||||
|
||||
# Additive counterparts to fn-ga-global-set-list, for the allow/deny commands.
|
||||
fn-ga-global-list-add() {
|
||||
declare KEY="$1" VALUE="$2"
|
||||
mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global"
|
||||
local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY"
|
||||
touch "$file"
|
||||
chmod 600 "$file"
|
||||
grep -qxF "$VALUE" "$file" || printf '%s\n' "$VALUE" >>"$file"
|
||||
}
|
||||
|
||||
fn-ga-global-list-remove() {
|
||||
declare KEY="$1" VALUE="$2"
|
||||
local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" tmp
|
||||
[[ -f "$file" ]] || return 0
|
||||
tmp="$(mktemp)"
|
||||
grep -vxF "$VALUE" "$file" >"$tmp" || true
|
||||
cat "$tmp" >"$file"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
fn-ga-global-list-contains() {
|
||||
declare KEY="$1" VALUE="$2"
|
||||
grep -qxF "$VALUE" "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" 2>/dev/null
|
||||
}
|
||||
|
||||
fn-ga-global-list-count() {
|
||||
declare KEY="$1"
|
||||
fn-ga-global-get-list "$KEY" | grep -c . || true
|
||||
}
|
||||
|
||||
# Entries end up in a comma/space separated env var, so they may contain
|
||||
# neither.
|
||||
fn-ga-validate-list-entry() {
|
||||
declare ENTRY="$1"
|
||||
[[ -n "$ENTRY" ]] || return 1
|
||||
! printf '%s' "$ENTRY" | grep -qE '[,[:space:]]'
|
||||
}
|
||||
|
||||
# "signal.org" and "@signal.org" name a domain; "guest@partner.com" names one
|
||||
# address. Callers strip any leading "@" themselves.
|
||||
fn-ga-allow-entry-kind() {
|
||||
declare ENTRY="$1"
|
||||
if [[ "$ENTRY" == @* || "$ENTRY" != *@* ]]; then
|
||||
echo domain
|
||||
else
|
||||
echo email
|
||||
fi
|
||||
}
|
||||
|
||||
# The service reads the allow/deny lists from its env file at startup, so
|
||||
# changing them means recreating the container.
|
||||
fn-ga-reload-service-config() {
|
||||
if fn-ga-service-running; then
|
||||
fn-ga-service-start
|
||||
else
|
||||
dokku_log_verbose "auth service is not running; changes apply when it starts"
|
||||
fi
|
||||
}
|
||||
|
||||
fn-ga-app-dir() {
|
||||
declare APP="$1"
|
||||
echo "$GOOGLE_AUTH_DATA_ROOT/apps/$APP"
|
||||
@@ -422,9 +481,10 @@ fn-ga-service-running() {
|
||||
|
||||
fn-ga-write-env-file() {
|
||||
local envfile="$GOOGLE_AUTH_DATA_ROOT/service.env"
|
||||
local domains emails
|
||||
local domains emails denied
|
||||
domains="$(fn-ga-global-get-list allowed-domains | paste -sd, -)"
|
||||
emails="$(fn-ga-global-get-list allowed-emails | paste -sd, -)"
|
||||
denied="$(fn-ga-global-get-list denied-emails | paste -sd, -)"
|
||||
mkdir -p "$GOOGLE_AUTH_DATA_ROOT"
|
||||
umask 077
|
||||
cat >"$envfile" <<EOF
|
||||
@@ -434,6 +494,7 @@ GOOGLE_AUTH_COOKIE_SECRET=$(fn-ga-global-get cookie-secret)
|
||||
GOOGLE_AUTH_AUTH_HOST=$(fn-ga-global-get auth-host)
|
||||
GOOGLE_AUTH_ALLOWED_DOMAINS=$domains
|
||||
GOOGLE_AUTH_ALLOWED_EMAILS=$emails
|
||||
GOOGLE_AUTH_DENIED_EMAILS=$denied
|
||||
GOOGLE_AUTH_COOKIE_NAME=$(fn-ga-global-get cookie-name _google_auth)
|
||||
GOOGLE_AUTH_SESSION_TTL=$(fn-ga-global-get session-ttl 24h)
|
||||
GOOGLE_AUTH_ALLOW_INSECURE=$(fn-ga-global-get allow-insecure false)
|
||||
|
||||
@@ -33,12 +33,17 @@ type Config struct {
|
||||
// has google-auth enabled).
|
||||
AuthHost string
|
||||
|
||||
// AllowedDomains / AllowedEmails control who may sign in. An email is
|
||||
// accepted if its domain is in AllowedDomains or the full address is in
|
||||
// AllowedEmails.
|
||||
// AllowedDomains / AllowedEmails are the allowlist: an email is accepted
|
||||
// if its domain is in AllowedDomains or the full address is in
|
||||
// AllowedEmails. Anything else is rejected.
|
||||
AllowedDomains []string
|
||||
AllowedEmails []string
|
||||
|
||||
// DeniedEmails is the denylist. It is checked before the allowlist and
|
||||
// always wins, so a single address can be revoked without dropping the
|
||||
// whole domain it belongs to.
|
||||
DeniedEmails []string
|
||||
|
||||
CookieName string
|
||||
SessionTTL time.Duration
|
||||
ListenAddr string
|
||||
@@ -79,6 +84,7 @@ func ConfigFromEnv() (Config, error) {
|
||||
cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@"))
|
||||
}
|
||||
cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS"))
|
||||
cfg.DeniedEmails = splitList(os.Getenv("GOOGLE_AUTH_DENIED_EMAILS"))
|
||||
|
||||
return cfg, cfg.validate()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("GOOGLE_AUTH_AUTH_HOST", "HTTPS://Auth.Example.com/")
|
||||
t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "Signal.org, @example.com")
|
||||
t.Setenv("GOOGLE_AUTH_ALLOWED_EMAILS", "Guest@Partner.com")
|
||||
t.Setenv("GOOGLE_AUTH_DENIED_EMAILS", "Former@Signal.org example@example.com")
|
||||
t.Setenv("GOOGLE_AUTH_SESSION_TTL", "48h")
|
||||
|
||||
cfg, err := ConfigFromEnv()
|
||||
@@ -62,6 +63,9 @@ func TestConfigFromEnv(t *testing.T) {
|
||||
if len(cfg.AllowedEmails) != 1 || cfg.AllowedEmails[0] != "guest@partner.com" {
|
||||
t.Errorf("AllowedEmails = %v", cfg.AllowedEmails)
|
||||
}
|
||||
if len(cfg.DeniedEmails) != 2 || cfg.DeniedEmails[0] != "former@signal.org" || cfg.DeniedEmails[1] != "example@example.com" {
|
||||
t.Errorf("DeniedEmails = %v", cfg.DeniedEmails)
|
||||
}
|
||||
if cfg.SessionTTL != 48*time.Hour {
|
||||
t.Errorf("SessionTTL = %v", cfg.SessionTTL)
|
||||
}
|
||||
|
||||
@@ -360,6 +360,68 @@ func TestEmailAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The denylist outranks both an allowed domain and an explicitly allowed
|
||||
// address.
|
||||
func TestEmailDenied(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
s.cfg.AllowedEmails = []string{"guest@partner.com"}
|
||||
s.cfg.DeniedEmails = []string{"former@signal.org", "guest@partner.com"}
|
||||
cases := map[string]bool{
|
||||
"greyson@signal.org": true,
|
||||
"former@signal.org": false,
|
||||
"FORMER@SIGNAL.ORG": false,
|
||||
"guest@partner.com": false,
|
||||
}
|
||||
for email, want := range cases {
|
||||
if got := s.emailAllowed(email); got != want {
|
||||
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsDeniedEmail(t *testing.T) {
|
||||
google := fakeGoogle(t, goodClaims())
|
||||
defer google.Close()
|
||||
s := newTestServer(t, google.URL)
|
||||
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
|
||||
|
||||
state := mustState(t, s, appHost, "/")
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
|
||||
w := do(s.Routes(), r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not allowed") {
|
||||
t.Fatalf("body should explain denial: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Denying an address invalidates the sessions it already holds, rather than
|
||||
// waiting for them to expire.
|
||||
func TestDenylistInvalidatesExistingSession(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
sess := sessionClaims{Email: "greyson@signal.org", User: "1", Host: appHost,
|
||||
Exp: time.Now().Add(time.Hour).Unix()}
|
||||
val, err := s.box.seal("session", sess)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newVerify := func() *http.Request {
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val})
|
||||
return r
|
||||
}
|
||||
|
||||
if w := do(s.Routes(), newVerify()); w.Code != http.StatusOK {
|
||||
t.Fatalf("before denial: got %d, want 200", w.Code)
|
||||
}
|
||||
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
|
||||
if w := do(s.Routes(), newVerify()); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("after denial: got %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlexClaims(t *testing.T) {
|
||||
var tok idToken
|
||||
payload := `{"aud":["a","b"],"email_verified":"true","iss":"accounts.google.com","exp":99}`
|
||||
|
||||
@@ -286,11 +286,22 @@ func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
|
||||
if sess.Host != requestHost(r) {
|
||||
return nil, false
|
||||
}
|
||||
// Re-check authorization on every request so allow/deny list changes take
|
||||
// effect immediately instead of whenever existing sessions happen to
|
||||
// expire.
|
||||
if !s.emailAllowed(sess.Email) {
|
||||
return nil, false
|
||||
}
|
||||
return &sess, true
|
||||
}
|
||||
|
||||
// emailAllowed applies the denylist first (it always wins), then the
|
||||
// allowlist: an address must match an allowed email or an allowed domain.
|
||||
func (s *Server) emailAllowed(email string) bool {
|
||||
email = strings.ToLower(email)
|
||||
if slices.Contains(s.cfg.DeniedEmails, email) {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(s.cfg.AllowedEmails, email) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,16 +24,20 @@ run = 'test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)'
|
||||
|
||||
[tasks.shellcheck]
|
||||
description = "Lint all plugin shell scripts"
|
||||
run = "shellcheck -x functions commands install nginx-pre-reload core-post-deploy post-delete post-app-rename post-app-clone subcommands/* test/nginx-conf-test.sh"
|
||||
run = "shellcheck -x functions commands install nginx-pre-reload core-post-deploy post-delete post-app-rename post-app-clone subcommands/* test/*.sh"
|
||||
|
||||
[tasks.test-nginx-conf]
|
||||
description = "Generate an nginx config from a fake app and validate it with real nginx (docker)"
|
||||
run = "test/nginx-conf-test.sh"
|
||||
|
||||
[tasks.test-access-lists]
|
||||
description = "Exercise the allow/deny list commands against a fake dokku layout"
|
||||
run = "test/access-list-test.sh"
|
||||
|
||||
[tasks.docker-build]
|
||||
description = "Build the auth service docker image"
|
||||
run = "docker build -t dokku-google-auth:latest ."
|
||||
|
||||
[tasks.check]
|
||||
description = "Run everything CI would run"
|
||||
depends = ["fmt-check", "vet", "test", "shellcheck", "test-nginx-conf"]
|
||||
depends = ["fmt-check", "vet", "test", "shellcheck", "test-nginx-conf", "test-access-lists"]
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
[[ $DOKKU_TRACE ]] && set -x
|
||||
source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions"
|
||||
|
||||
cmd-google-auth-allow() {
|
||||
declare desc="allow a domain or address to sign in"
|
||||
local cmd="google-auth:allow"
|
||||
[[ "$1" == "$cmd" ]] && shift 1
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
dokku_log_info2 "google-auth allow list"
|
||||
local listed found=false
|
||||
while IFS= read -r listed; do
|
||||
[[ -z "$listed" ]] && continue
|
||||
dokku_log_verbose "$listed (domain)"
|
||||
found=true
|
||||
done < <(fn-ga-global-get-list allowed-domains)
|
||||
while IFS= read -r listed; do
|
||||
[[ -z "$listed" ]] && continue
|
||||
dokku_log_verbose "$listed"
|
||||
found=true
|
||||
done < <(fn-ga-global-get-list allowed-emails)
|
||||
[[ "$found" == "false" ]] && dokku_log_verbose "(empty — nobody can sign in)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local entry
|
||||
for entry in "$@"; do
|
||||
fn-ga-validate-list-entry "$entry" ||
|
||||
dokku_log_fail "invalid entry '$entry' — use a domain (signal.org) or a full address (guest@partner.com), with no spaces or commas"
|
||||
entry="${entry,,}"
|
||||
if [[ "$(fn-ga-allow-entry-kind "$entry")" == "domain" ]]; then
|
||||
entry="${entry#@}"
|
||||
[[ "$entry" == *.* ]] || dokku_log_fail "'$entry' does not look like a domain"
|
||||
fn-ga-global-list-add allowed-domains "$entry"
|
||||
dokku_log_info1 "allowed domain: $entry (any verified account at $entry)"
|
||||
else
|
||||
fn-ga-global-list-add allowed-emails "$entry"
|
||||
dokku_log_info1 "allowed: $entry"
|
||||
if fn-ga-global-list-contains denied-emails "$entry"; then
|
||||
dokku_log_warn "$entry is on the deny list, which wins — run: dokku google-auth:undeny $entry"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
fn-ga-reload-service-config
|
||||
}
|
||||
|
||||
cmd-google-auth-allow "$@"
|
||||
Vendored
+25
-2
@@ -8,7 +8,8 @@ cmd-google-auth-configure() {
|
||||
local cmd="google-auth:configure"
|
||||
[[ "$1" == "$cmd" ]] && shift 1
|
||||
|
||||
local domains=() emails=() domains_given=false emails_given=false
|
||||
local domains=() emails=() denied=()
|
||||
local domains_given=false emails_given=false denied_given=false
|
||||
local old_port
|
||||
old_port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
|
||||
|
||||
@@ -44,6 +45,17 @@ cmd-google-auth-configure() {
|
||||
emails+=("${2,,}")
|
||||
shift 2
|
||||
;;
|
||||
--deny-email)
|
||||
[[ -n "${2:-}" ]] || dokku_log_fail "--deny-email requires a value"
|
||||
[[ "$2" == *@* ]] || dokku_log_fail "--deny-email takes a full address (got '$2'); denying a whole domain means removing its --allow-domain"
|
||||
denied_given=true
|
||||
denied+=("${2,,}")
|
||||
shift 2
|
||||
;;
|
||||
--clear-deny-emails)
|
||||
denied_given=true
|
||||
shift 1
|
||||
;;
|
||||
--session-ttl)
|
||||
[[ "${2:-}" =~ ^[0-9]+(h|m|s)$ ]] || dokku_log_fail "--session-ttl must look like 24h, 30m, or 3600s"
|
||||
fn-ga-global-set session-ttl "$2"
|
||||
@@ -75,9 +87,20 @@ cmd-google-auth-configure() {
|
||||
esac
|
||||
done
|
||||
|
||||
# Replace the allow lists only when new values were passed.
|
||||
# Replace the allow/deny lists only when new values were passed.
|
||||
[[ "$domains_given" == "true" ]] && fn-ga-global-set-list allowed-domains "${domains[@]}"
|
||||
[[ "$emails_given" == "true" ]] && fn-ga-global-set-list allowed-emails "${emails[@]}"
|
||||
[[ "$denied_given" == "true" ]] && fn-ga-global-set-list denied-emails "${denied[@]}"
|
||||
|
||||
# Deny wins over allow; surface the contradiction instead of silently
|
||||
# ignoring the allow entry.
|
||||
local addr
|
||||
while IFS= read -r addr; do
|
||||
[[ -z "$addr" ]] && continue
|
||||
if fn-ga-global-get-list allowed-emails | grep -qxF "$addr"; then
|
||||
dokku_log_warn "$addr is in both the allow and deny lists; it will be denied"
|
||||
fi
|
||||
done < <(fn-ga-global-get-list denied-emails)
|
||||
|
||||
# First run: generate the cookie secret automatically.
|
||||
if [[ -z "$(fn-ga-global-get cookie-secret)" ]]; then
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
[[ $DOKKU_TRACE ]] && set -x
|
||||
source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions"
|
||||
|
||||
cmd-google-auth-deny() {
|
||||
declare desc="block an address even if the allow list covers it"
|
||||
local cmd="google-auth:deny"
|
||||
[[ "$1" == "$cmd" ]] && shift 1
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
dokku_log_info2 "google-auth deny list"
|
||||
local listed found=false
|
||||
while IFS= read -r listed; do
|
||||
[[ -z "$listed" ]] && continue
|
||||
dokku_log_verbose "$listed"
|
||||
found=true
|
||||
done < <(fn-ga-global-get-list denied-emails)
|
||||
[[ "$found" == "false" ]] && dokku_log_verbose "(empty)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local entry
|
||||
for entry in "$@"; do
|
||||
fn-ga-validate-list-entry "$entry" ||
|
||||
dokku_log_fail "invalid entry '$entry' — use a full address (former@signal.org), with no spaces or commas"
|
||||
entry="${entry,,}"
|
||||
[[ "$entry" == *@* ]] ||
|
||||
dokku_log_fail "google-auth:deny takes a full address, not a domain; to stop allowing all of '$entry', run: dokku google-auth:unallow $entry"
|
||||
fn-ga-global-list-add denied-emails "$entry"
|
||||
dokku_log_info1 "denied: $entry ($(fn-ga-global-list-count denied-emails) denied)"
|
||||
done
|
||||
|
||||
dokku_log_verbose "denied users lose access on their next request; existing sessions are not honored"
|
||||
fn-ga-reload-service-config
|
||||
}
|
||||
|
||||
cmd-google-auth-deny "$@"
|
||||
@@ -55,6 +55,7 @@ cmd-google-auth-report() {
|
||||
dokku_log_verbose "Callback URL: https://$(fn-ga-global-get auth-host '<auth-host>')${GOOGLE_AUTH_ROUTE_PREFIX}/callback"
|
||||
dokku_log_verbose "Allowed domains: $(fn-ga-global-get-list allowed-domains | paste -sd' ' -)"
|
||||
dokku_log_verbose "Allowed emails: $(fn-ga-global-get-list allowed-emails | paste -sd' ' -)"
|
||||
dokku_log_verbose "Denied emails: $(fn-ga-global-get-list denied-emails | paste -sd' ' -)"
|
||||
dokku_log_verbose "Session TTL: $(fn-ga-global-get session-ttl 24h)"
|
||||
dokku_log_verbose "Service port: 127.0.0.1:$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
|
||||
if fn-ga-service-running; then
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
[[ $DOKKU_TRACE ]] && set -x
|
||||
source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions"
|
||||
|
||||
cmd-google-auth-unallow() {
|
||||
declare desc="remove a domain or address from the allow list"
|
||||
local cmd="google-auth:unallow"
|
||||
[[ "$1" == "$cmd" ]] && shift 1
|
||||
|
||||
[[ $# -gt 0 ]] || dokku_log_fail "usage: dokku google-auth:unallow <domain|address...> (see: dokku google-auth:allow)"
|
||||
|
||||
# Lowercase, drop any leading "@", and de-duplicate so the lockout check
|
||||
# below counts each entry once.
|
||||
local entries=()
|
||||
mapfile -t entries < <(printf '%s\n' "$@" | tr '[:upper:]' '[:lower:]' | sed 's/^@//' | awk '!seen[$0]++')
|
||||
|
||||
# Refuse before touching anything if this would leave the allow list empty,
|
||||
# which locks everyone out of every enabled app.
|
||||
local entry total present=0
|
||||
total=$(($(fn-ga-global-list-count allowed-domains) + $(fn-ga-global-list-count allowed-emails)))
|
||||
for entry in "${entries[@]}"; do
|
||||
fn-ga-global-list-contains allowed-domains "$entry" && present=$((present + 1))
|
||||
fn-ga-global-list-contains allowed-emails "$entry" && present=$((present + 1))
|
||||
done
|
||||
if [[ $((total - present)) -le 0 ]]; then
|
||||
dokku_log_fail "that would empty the allow list and lock everyone out; add the replacement first with: dokku google-auth:allow <domain|address>"
|
||||
fi
|
||||
|
||||
local removed
|
||||
for entry in "${entries[@]}"; do
|
||||
removed=false
|
||||
if fn-ga-global-list-contains allowed-domains "$entry"; then
|
||||
fn-ga-global-list-remove allowed-domains "$entry"
|
||||
dokku_log_info1 "removed allowed domain: $entry"
|
||||
removed=true
|
||||
fi
|
||||
if fn-ga-global-list-contains allowed-emails "$entry"; then
|
||||
fn-ga-global-list-remove allowed-emails "$entry"
|
||||
dokku_log_info1 "removed: $entry"
|
||||
removed=true
|
||||
fi
|
||||
[[ "$removed" == "true" ]] || dokku_log_warn "$entry was not on the allow list"
|
||||
done
|
||||
|
||||
dokku_log_verbose "removed users lose access on their next request; existing sessions are not honored"
|
||||
fn-ga-reload-service-config
|
||||
}
|
||||
|
||||
cmd-google-auth-unallow "$@"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
[[ $DOKKU_TRACE ]] && set -x
|
||||
source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions"
|
||||
|
||||
cmd-google-auth-undeny() {
|
||||
declare desc="remove an address from the deny list"
|
||||
local cmd="google-auth:undeny"
|
||||
[[ "$1" == "$cmd" ]] && shift 1
|
||||
|
||||
[[ $# -gt 0 ]] || dokku_log_fail "usage: dokku google-auth:undeny <address...> (see: dokku google-auth:deny)"
|
||||
|
||||
local entry
|
||||
for entry in "$@"; do
|
||||
entry="${entry,,}"
|
||||
if ! fn-ga-global-list-contains denied-emails "$entry"; then
|
||||
dokku_log_warn "$entry was not on the deny list"
|
||||
continue
|
||||
fi
|
||||
fn-ga-global-list-remove denied-emails "$entry"
|
||||
dokku_log_info1 "removed from deny list: $entry"
|
||||
# Undenying only stops the explicit block; the allow list still decides.
|
||||
if ! fn-ga-global-list-contains allowed-emails "$entry" &&
|
||||
! fn-ga-global-list-contains allowed-domains "${entry#*@}"; then
|
||||
dokku_log_warn "$entry still cannot sign in — nothing on the allow list covers it (see: dokku google-auth:allow)"
|
||||
fi
|
||||
done
|
||||
|
||||
fn-ga-reload-service-config
|
||||
}
|
||||
|
||||
cmd-google-auth-undeny "$@"
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercises the allow/deny list helpers and the google-auth:allow / :unallow /
|
||||
# :deny / :undeny subcommands against a fake dokku layout, and checks that the
|
||||
# lists reach the env file the auth service reads.
|
||||
set -eo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Fake dokku host layout.
|
||||
export DOKKU_ROOT="$WORK/dokku-root"
|
||||
export DOKKU_LIB_ROOT="$WORK/dokku-lib"
|
||||
export PLUGIN_CORE_AVAILABLE_PATH="$WORK/nonexistent" # force built-in fallbacks
|
||||
GLOBAL="$DOKKU_LIB_ROOT/data/google-auth/global"
|
||||
mkdir -p "$GLOBAL"
|
||||
|
||||
# Stub docker so the subcommands see the service as not running and never touch
|
||||
# a real container. This test is about list handling, not container management.
|
||||
mkdir -p "$WORK/bin"
|
||||
printf '#!/bin/sh\nexit 1\n' >"$WORK/bin/docker"
|
||||
chmod +x "$WORK/bin/docker"
|
||||
export PATH="$WORK/bin:$PATH"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/functions"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" 1>&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ga() {
|
||||
local sub="$1"
|
||||
shift
|
||||
"$ROOT/subcommands/$sub" "google-auth:$sub" "$@"
|
||||
}
|
||||
|
||||
# expect_output <needle> <subcommand> [args...] — asserts the subcommand's
|
||||
# output (stdout + stderr) contains <needle>. Collects the output first rather
|
||||
# than piping into grep, which would SIGPIPE the writer under pipefail.
|
||||
expect_output() {
|
||||
local needle="$1" out
|
||||
shift
|
||||
out="$(ga "$@" 2>&1 || true)"
|
||||
grep -qF "$needle" <<<"$out" || fail "expected '$needle' in google-auth:$1 output, got: $out"
|
||||
}
|
||||
|
||||
# --- entry validation and classification ---
|
||||
fn-ga-validate-list-entry "guest@partner.com" || fail "address should be a valid entry"
|
||||
fn-ga-validate-list-entry "signal.org" || fail "domain should be a valid entry"
|
||||
fn-ga-validate-list-entry "a@b.com,c@d.com" && fail "comma should be rejected (it splits the env var)"
|
||||
fn-ga-validate-list-entry "not a domain" && fail "whitespace should be rejected"
|
||||
fn-ga-validate-list-entry "" && fail "empty entry should be rejected"
|
||||
[[ "$(fn-ga-allow-entry-kind "signal.org")" == "domain" ]] || fail "bare domain misclassified"
|
||||
[[ "$(fn-ga-allow-entry-kind "@signal.org")" == "domain" ]] || fail "@domain misclassified"
|
||||
[[ "$(fn-ga-allow-entry-kind "guest@partner.com")" == "email" ]] || fail "address misclassified"
|
||||
echo "ok: entry validation and classification"
|
||||
|
||||
# --- list helpers ---
|
||||
fn-ga-global-list-add allowed-emails "a@x.com"
|
||||
fn-ga-global-list-add allowed-emails "a@x.com" # idempotent
|
||||
[[ "$(fn-ga-global-list-count allowed-emails)" -eq 1 ]] || fail "duplicate add should be a no-op"
|
||||
fn-ga-global-list-contains allowed-emails "a@x.com" || fail "contains should find the entry"
|
||||
fn-ga-global-list-contains allowed-emails "a@x.co" && fail "contains should match whole lines only"
|
||||
fn-ga-global-list-remove allowed-emails "a@x.com"
|
||||
[[ "$(fn-ga-global-list-count allowed-emails)" -eq 0 ]] || fail "remove should empty the list"
|
||||
fn-ga-global-list-remove allowed-emails "nope@x.com" # missing entry is not an error
|
||||
echo "ok: list helpers"
|
||||
|
||||
# --- allow ---
|
||||
ga allow >/dev/null || fail "listing an empty allow list should succeed"
|
||||
ga allow Signal.org @Example.com Guest@Partner.com >/dev/null
|
||||
grep -qx "signal.org" "$GLOBAL/allowed-domains" || fail "bare domain should be stored lowercased"
|
||||
grep -qx "example.com" "$GLOBAL/allowed-domains" || fail "@domain should be stored without the @"
|
||||
grep -qx "guest@partner.com" "$GLOBAL/allowed-emails" || fail "address should be stored lowercased"
|
||||
ga allow "a@b.com,c@d.com" 2>/dev/null && fail "allow should reject an entry with a comma"
|
||||
ga allow "localhost" 2>/dev/null && fail "allow should reject a domain with no dot"
|
||||
[[ "$(fn-ga-global-list-count allowed-domains)" -eq 2 ]] || fail "rejected entries should not be stored"
|
||||
echo "ok: allow"
|
||||
|
||||
# --- deny wins, and contradictions are surfaced ---
|
||||
ga deny Former@Signal.org >/dev/null
|
||||
grep -qx "former@signal.org" "$GLOBAL/denied-emails" || fail "deny should store the address lowercased"
|
||||
ga deny signal.org 2>/dev/null && fail "deny should reject a bare domain"
|
||||
ga deny guest@partner.com >/dev/null
|
||||
# guest@partner.com is on both lists now; allow must say deny wins.
|
||||
expect_output "deny list" allow guest@partner.com
|
||||
echo "ok: deny"
|
||||
|
||||
# --- undeny ---
|
||||
ga undeny Guest@Partner.com >/dev/null
|
||||
fn-ga-global-list-contains denied-emails "guest@partner.com" && fail "undeny should remove the address"
|
||||
expect_output "was not on the deny list" undeny never@denied.com
|
||||
ga deny stranger@elsewhere.com >/dev/null
|
||||
expect_output "still cannot sign in" undeny stranger@elsewhere.com
|
||||
echo "ok: undeny"
|
||||
|
||||
# --- unallow, including the lockout guardrail ---
|
||||
ga unallow @Example.com >/dev/null
|
||||
fn-ga-global-list-contains allowed-domains "example.com" && fail "unallow should remove the domain"
|
||||
expect_output "was not on the allow list" unallow absent@nowhere.com
|
||||
|
||||
# signal.org + guest@partner.com remain; removing both (with a duplicate to
|
||||
# check de-duplication) must be refused, and must not change anything.
|
||||
before="$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")"
|
||||
ga unallow signal.org guest@partner.com SIGNAL.ORG 2>/dev/null &&
|
||||
fail "unallow should refuse to empty the allow list"
|
||||
[[ "$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")" == "$before" ]] ||
|
||||
fail "a refused unallow must leave the lists untouched"
|
||||
ga unallow guest@partner.com >/dev/null || fail "unallow should still remove a non-final entry"
|
||||
[[ "$(fn-ga-global-list-count allowed-domains)" -eq 1 ]] || fail "signal.org should remain allowed"
|
||||
echo "ok: unallow and lockout guardrail"
|
||||
|
||||
# --- configure's replace-the-list flags stay consistent with the above ---
|
||||
"$ROOT/subcommands/configure" google-auth:configure \
|
||||
--allow-domain signal.org --deny-email one@signal.org --deny-email two@signal.org >/dev/null 2>&1 || true
|
||||
[[ "$(fn-ga-global-list-count denied-emails)" -eq 2 ]] || fail "--deny-email should replace the deny list"
|
||||
"$ROOT/subcommands/configure" google-auth:configure --clear-deny-emails >/dev/null 2>&1 || true
|
||||
[[ "$(fn-ga-global-list-count denied-emails)" -eq 0 ]] || fail "--clear-deny-emails should empty the deny list"
|
||||
echo "ok: configure flags"
|
||||
|
||||
# --- the lists reach the service env file ---
|
||||
ga deny former@signal.org >/dev/null
|
||||
ga allow guest@partner.com >/dev/null
|
||||
fn-ga-write-env-file
|
||||
ENV_FILE="$DOKKU_LIB_ROOT/data/google-auth/service.env"
|
||||
grep -qx "GOOGLE_AUTH_ALLOWED_DOMAINS=signal.org" "$ENV_FILE" || fail "env file missing allowed domains"
|
||||
grep -qx "GOOGLE_AUTH_ALLOWED_EMAILS=guest@partner.com" "$ENV_FILE" || fail "env file missing allowed emails"
|
||||
grep -qx "GOOGLE_AUTH_DENIED_EMAILS=former@signal.org" "$ENV_FILE" || fail "env file missing denied emails"
|
||||
echo "ok: service env file"
|
||||
|
||||
echo "ALL ACCESS LIST TESTS PASSED"
|
||||
Reference in New Issue
Block a user