Allow per-app allow/deny.
This commit is contained in:
@@ -25,6 +25,7 @@ type Server struct {
|
||||
cfg Config
|
||||
box *box
|
||||
nonces *nonceCache
|
||||
apps *appStore
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ func New(cfg Config) (*Server, error) {
|
||||
cfg: cfg,
|
||||
box: b,
|
||||
nonces: newNonceCache(),
|
||||
apps: newAppStore(cfg.AppConfigDir),
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
@@ -96,8 +98,10 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
app := appFromRequest(r)
|
||||
st := stateClaims{
|
||||
Host: host,
|
||||
App: app,
|
||||
RD: rd,
|
||||
Proto: s.proto(r),
|
||||
Nonce: randToken(),
|
||||
@@ -115,9 +119,9 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid email profile")
|
||||
q.Set("state", token)
|
||||
if len(s.cfg.AllowedDomains) == 1 {
|
||||
// UX hint only; real enforcement happens in emailAllowed.
|
||||
q.Set("hd", s.cfg.AllowedDomains[0])
|
||||
if domains := s.effectiveLists(app).AllowedDomains; len(domains) == 1 {
|
||||
// UX hint only; real enforcement happens in emailAllowedFor.
|
||||
q.Set("hd", domains[0])
|
||||
}
|
||||
http.Redirect(w, r, s.cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
@@ -157,9 +161,11 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.htmlError(w, http.StatusForbidden, "Google returned an invalid identity token.")
|
||||
return
|
||||
}
|
||||
// Authorize against the app the user is signing in to, not the app serving
|
||||
// this callback — those differ whenever the auth host belongs elsewhere.
|
||||
email := strings.ToLower(idTok.Email)
|
||||
if !s.emailAllowed(email) {
|
||||
log.Printf("callback: denied %s (not in allowed domains/emails) for host %s", email, st.Host)
|
||||
if !s.emailAllowedFor(st.App, email) {
|
||||
log.Printf("callback: denied %s for app %q on host %s (not allowed)", email, st.App, st.Host)
|
||||
s.htmlError(w, http.StatusForbidden,
|
||||
"You are signed in to Google as <b>"+html.EscapeString(email)+"</b>, but that account is not allowed to access this app.")
|
||||
return
|
||||
@@ -288,28 +294,64 @@ func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
|
||||
}
|
||||
// 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) {
|
||||
// expire. This is also what keeps a session minted for one app from being
|
||||
// accepted by an app with stricter lists.
|
||||
if !s.emailAllowedFor(appFromRequest(r), 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 {
|
||||
// effectiveLists resolves the rules that apply to one app: the app's own allow
|
||||
// rules replace the global ones when it has any (so an app can be narrowed to a
|
||||
// few people, or opened to an outside collaborator, independently of the
|
||||
// global list), while deny lists are combined so a global denial can never be
|
||||
// undone by an app's config.
|
||||
func (s *Server) effectiveLists(app string) accessLists {
|
||||
own := s.apps.lists(app)
|
||||
out := accessLists{
|
||||
AllowedDomains: s.cfg.AllowedDomains,
|
||||
AllowedEmails: s.cfg.AllowedEmails,
|
||||
DeniedEmails: s.cfg.DeniedEmails,
|
||||
}
|
||||
if own.hasAllowRules() {
|
||||
out.AllowedDomains = own.AllowedDomains
|
||||
out.AllowedEmails = own.AllowedEmails
|
||||
}
|
||||
if len(own.DeniedEmails) > 0 {
|
||||
out.DeniedEmails = append(append([]string{}, out.DeniedEmails...), own.DeniedEmails...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emailAllowedFor applies app's rules: the deny list first (it always wins),
|
||||
// then the allow list, where an address must match an allowed email or an
|
||||
// allowed domain. An empty app means "global rules only".
|
||||
func (s *Server) emailAllowedFor(app, email string) bool {
|
||||
email = strings.ToLower(email)
|
||||
if slices.Contains(s.cfg.DeniedEmails, email) {
|
||||
lists := s.effectiveLists(app)
|
||||
if slices.Contains(lists.DeniedEmails, email) {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(s.cfg.AllowedEmails, email) {
|
||||
if slices.Contains(lists.AllowedEmails, email) {
|
||||
return true
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(s.cfg.AllowedDomains, email[at+1:])
|
||||
return slices.Contains(lists.AllowedDomains, email[at+1:])
|
||||
}
|
||||
|
||||
// appFromRequest reads the app name nginx stamps on every request that reaches
|
||||
// this service. Clients cannot influence it: each generated location sets the
|
||||
// header explicitly, replacing whatever arrived from outside.
|
||||
func appFromRequest(r *http.Request) string {
|
||||
app := strings.TrimSpace(r.Header.Get(AppHeader))
|
||||
if !validAppName(app) {
|
||||
return ""
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (s *Server) redirectURI() string {
|
||||
|
||||
Reference in New Issue
Block a user