Add explict allow deny commands.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user