go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth_service/services/frontend/main.go (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package main is the main point of entry for the frontend module.
    16  //
    17  // It exposes the main API and Web UI of the service.
    18  package main
    19  
    20  import (
    21  	"context"
    22  	"encoding/json"
    23  	"errors"
    24  	"fmt"
    25  	"net/http"
    26  	"os"
    27  	"strings"
    28  
    29  	"google.golang.org/grpc/codes"
    30  	"google.golang.org/grpc/status"
    31  
    32  	"go.chromium.org/luci/auth/identity"
    33  	"go.chromium.org/luci/common/logging"
    34  	"go.chromium.org/luci/grpc/grpcutil"
    35  	"go.chromium.org/luci/server"
    36  	"go.chromium.org/luci/server/auth"
    37  	srvauthdb "go.chromium.org/luci/server/auth/authdb"
    38  	"go.chromium.org/luci/server/auth/openid"
    39  	"go.chromium.org/luci/server/auth/xsrf"
    40  	"go.chromium.org/luci/server/encryptedcookies"
    41  	"go.chromium.org/luci/server/module"
    42  	"go.chromium.org/luci/server/router"
    43  	"go.chromium.org/luci/server/templates"
    44  
    45  	"go.chromium.org/luci/auth_service/api/internalspb"
    46  	"go.chromium.org/luci/auth_service/api/rpcpb"
    47  	"go.chromium.org/luci/auth_service/impl"
    48  	"go.chromium.org/luci/auth_service/impl/model"
    49  	"go.chromium.org/luci/auth_service/impl/servers/accounts"
    50  	"go.chromium.org/luci/auth_service/impl/servers/allowlists"
    51  	"go.chromium.org/luci/auth_service/impl/servers/authdb"
    52  	"go.chromium.org/luci/auth_service/impl/servers/changelogs"
    53  	"go.chromium.org/luci/auth_service/impl/servers/groups"
    54  	"go.chromium.org/luci/auth_service/impl/servers/imports"
    55  	"go.chromium.org/luci/auth_service/impl/servers/internals"
    56  	"go.chromium.org/luci/auth_service/impl/servers/oauth"
    57  	"go.chromium.org/luci/auth_service/services/frontend/subscription"
    58  
    59  	// Ensure registration of validation rules.
    60  	_ "go.chromium.org/luci/auth_service/internal/configs/validation"
    61  	// Store auth sessions in the datastore.
    62  	_ "go.chromium.org/luci/server/encryptedcookies/session/datastore"
    63  )
    64  
    65  func main() {
    66  	modules := []module.Module{
    67  		encryptedcookies.NewModuleFromFlags(), // for authenticating web UI calls
    68  	}
    69  
    70  	// Parse flags from environment variables.
    71  	dryRunAPIChange := model.ParseDryRunEnvVar(model.DryRunAPIChangesEnvVar)
    72  	enableGroupImports := model.ParseEnableEnvVar(model.EnableGroupImportsEnvVar)
    73  
    74  	impl.Main(modules, func(srv *server.Server) error {
    75  		// On GAE '/static' is served by GAE itself (see app.yaml). When running
    76  		// locally in dev mode we need to do it ourselves.
    77  		if !srv.Options.Prod {
    78  			srv.Routes.Static("/static", nil, http.Dir("./static"))
    79  		}
    80  
    81  		// Cookie auth and pRPC have some rough edges, see prpcCookieAuth comment.
    82  		prpcAuth := &prpcCookieAuth{cookieAuth: srv.CookieAuth}
    83  
    84  		// Authentication methods for RPC APIs.
    85  		srv.SetRPCAuthMethods([]auth.Method{
    86  			// The preferred authentication method.
    87  			&openid.GoogleIDTokenAuthMethod{
    88  				AudienceCheck: openid.AudienceMatchesHost,
    89  				SkipNonJWT:    true, // pass OAuth2 access tokens through
    90  			},
    91  			// Backward compatibility for the RPC Explorer and old clients.
    92  			&auth.GoogleOAuth2Method{
    93  				Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
    94  			},
    95  			// Cookie auth is used by the Web UI. When this method is used, we also
    96  			// check the XSRF token to be really sure it is the Web UI that called
    97  			// the method. See xsrf.Interceptor below.
    98  			prpcAuth,
    99  		})
   100  
   101  		// Interceptors applying to all RPC APIs.
   102  		srv.RegisterUnifiedServerInterceptors(
   103  			xsrf.Interceptor(prpcAuth),
   104  			impl.AuthorizeRPCAccess,
   105  		)
   106  
   107  		authdbServer := &authdb.Server{}
   108  
   109  		// Register all RPC servers.
   110  		internalspb.RegisterInternalsServer(srv, &internals.Server{})
   111  		rpcpb.RegisterAccountsServer(srv, &accounts.Server{})
   112  		rpcpb.RegisterGroupsServer(srv, groups.NewServer(dryRunAPIChange))
   113  		rpcpb.RegisterAllowlistsServer(srv, &allowlists.Server{})
   114  		rpcpb.RegisterAuthDBServer(srv, authdbServer)
   115  		rpcpb.RegisterChangeLogsServer(srv, &changelogs.Server{})
   116  
   117  		// The middleware chain applied to all plain HTTP routes.
   118  		mw := router.MiddlewareChain{
   119  			templates.WithTemplates(prepareTemplates(&srv.Options)),
   120  			auth.Authenticate(srv.CookieAuth),
   121  			requireLogin,
   122  			authorizeUIAccess,
   123  		}
   124  
   125  		// The middleware chain for API like routes.
   126  		apiMw := router.MiddlewareChain{
   127  			auth.Authenticate(
   128  				// The preferred authentication method.
   129  				&openid.GoogleIDTokenAuthMethod{
   130  					AudienceCheck: openid.AudienceMatchesHost,
   131  					SkipNonJWT:    true, // pass OAuth2 access tokens through
   132  				},
   133  				// Backward compatibility for the RPC Explorer and old clients.
   134  				&auth.GoogleOAuth2Method{
   135  					Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
   136  				},
   137  			),
   138  			authorizeAPIAccess,
   139  		}
   140  
   141  		srv.Routes.GET("/", mw, func(ctx *router.Context) {
   142  			http.Redirect(ctx.Writer, ctx.Request, "/groups", http.StatusFound)
   143  		})
   144  		srv.Routes.GET("/groups", mw, func(ctx *router.Context) {
   145  			templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/groups.html", nil)
   146  		})
   147  		// Note that external groups have "/" in their names.
   148  		srv.Routes.GET("/groups/*groupName", mw, func(ctx *router.Context) {
   149  			templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/groups.html", nil)
   150  		})
   151  		srv.Routes.GET("/change_log", mw, func(ctx *router.Context) {
   152  			templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/change_log.html", nil)
   153  		})
   154  		srv.Routes.GET("/ip_allowlists", mw, func(ctx *router.Context) {
   155  			templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/ip_allowlists.html", nil)
   156  		})
   157  		srv.Routes.GET("/lookup", mw, func(ctx *router.Context) {
   158  			templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/lookup.html", nil)
   159  		})
   160  
   161  		// For PubSub subscriber and AuthDB Google Storage reader authorization.
   162  		//
   163  		// Note: the endpoint path is unchanged as there are no API changes,
   164  		// and it's specified in
   165  		// https://pkg.go.dev/go.chromium.org/luci/server/auth/service#AuthService.RequestAccess
   166  		srv.Routes.GET("/auth_service/api/v1/authdb/subscription/authorization", apiMw, adaptGrpcErr(subscription.CheckAccess))
   167  		srv.Routes.POST("/auth_service/api/v1/authdb/subscription/authorization", apiMw, adaptGrpcErr(subscription.Authorize))
   168  		srv.Routes.DELETE("/auth_service/api/v1/authdb/subscription/authorization", apiMw, adaptGrpcErr(subscription.Deauthorize))
   169  
   170  		// Legacy authdbrevision serving.
   171  		// TODO(cjacomet): Add smoke test for this endpoint
   172  		srv.Routes.GET("/auth_service/api/v1/authdb/revisions/:revID", apiMw, adaptGrpcErr(authdbServer.HandleLegacyAuthDBServing))
   173  		srv.Routes.GET("/auth/api/v1/server/oauth_config", nil, adaptGrpcErr(oauth.HandleLegacyOAuthEndpoint))
   174  		if enableGroupImports {
   175  			srv.Routes.PUT("/auth_service/api/v1/importer/ingest_tarball/:tarballName", apiMw, adaptGrpcErr(imports.HandleTarballIngestHandler))
   176  		}
   177  
   178  		// Endpoint to serve the V2AuthDBSnapshot for validation of Auth
   179  		// Service v2.
   180  		//
   181  		// TODO: Remove this and its handler once we have fully rolled out
   182  		// Auth Service v2 (b/321019030).
   183  		srv.Routes.GET("/auth_service/api/v2/authdb/revisions/:revID", apiMw, adaptGrpcErr(authdbServer.HandleV2AuthDBServing))
   184  
   185  		return nil
   186  	})
   187  }
   188  
   189  // prpcCookieAuth authenticates pRPC calls using the given method, but only
   190  // if they have `X-Xsrf-Token` header. Otherwise it ignores cookies completely.
   191  //
   192  // This is primarily needed to allow the RPC Explorer to keep sending cookies
   193  // without XSRF tokens, since it is unaware of XSRF tokens (or cookies for that
   194  // matter) and just uses XMLHttpRequest, which **always** sends cookies with
   195  // same origin requests. There's no way to disable it. Such requests are
   196  // rejected by xsrf.Interceptor, because they don't have XSRF tokens.
   197  //
   198  // The best solution would be to change the RPC Explorer to use `fetch` API with
   199  // 'credentials: omit' policy. But this is non-trivial. So instead we just
   200  // ignore any cookies sent by the RPC Explorer and let it authenticate calls
   201  // using OAuth2 access tokens (as it was designed to do).
   202  type prpcCookieAuth struct {
   203  	cookieAuth auth.Method
   204  }
   205  
   206  // Authenticate is a part of auth.Method interface.
   207  func (m *prpcCookieAuth) Authenticate(ctx context.Context, req auth.RequestMetadata) (*auth.User, auth.Session, error) {
   208  	if req.Header(xsrf.XSRFTokenMetadataKey) != "" {
   209  		return m.cookieAuth.Authenticate(ctx, req)
   210  	}
   211  	return nil, nil, nil // skip this method
   212  }
   213  
   214  func prepareTemplates(opts *server.Options) *templates.Bundle {
   215  	return &templates.Bundle{
   216  		Loader:          templates.FileSystemLoader(os.DirFS("templates")),
   217  		DebugMode:       func(context.Context) bool { return !opts.Prod },
   218  		DefaultTemplate: "base",
   219  		DefaultArgs: func(ctx context.Context, e *templates.Extra) (templates.Args, error) {
   220  			logoutURL, err := auth.LogoutURL(ctx, e.Request.URL.RequestURI())
   221  			if err != nil {
   222  				return nil, err
   223  			}
   224  			token, err := xsrf.Token(ctx)
   225  			if err != nil {
   226  				return nil, err
   227  			}
   228  			isAdmin, err := auth.IsMember(ctx, model.AdminGroup)
   229  			if err != nil {
   230  				return nil, err
   231  			}
   232  			return templates.Args{
   233  				"AppVersion": opts.ImageVersion(),
   234  				"User":       auth.CurrentUser(ctx),
   235  				"IsAdmin":    isAdmin,
   236  				"LogoutURL":  logoutURL,
   237  				"XSRFToken":  token,
   238  			}, nil
   239  		},
   240  	}
   241  }
   242  
   243  // requireLogin redirect anonymous users to the login page.
   244  func requireLogin(ctx *router.Context, next router.Handler) {
   245  	if auth.CurrentIdentity(ctx.Request.Context()) != identity.AnonymousIdentity {
   246  		next(ctx) // already logged in
   247  		return
   248  	}
   249  
   250  	loginURL, err := auth.LoginURL(ctx.Request.Context(), ctx.Request.URL.RequestURI())
   251  	if err != nil {
   252  		replyError(ctx, err, "Failed to generate the login URL", http.StatusInternalServerError)
   253  		return
   254  	}
   255  
   256  	http.Redirect(ctx.Writer, ctx.Request, loginURL, http.StatusFound)
   257  }
   258  
   259  // authorizeUIAccess checks the user is allowed to access the web UI.
   260  func authorizeUIAccess(ctx *router.Context, next router.Handler) {
   261  	switch yes, err := auth.IsMember(ctx.Request.Context(), srvauthdb.AuthServiceAccessGroup); {
   262  	case err != nil:
   263  		replyError(ctx, err, "Failed to check group membership", http.StatusInternalServerError)
   264  	case !yes:
   265  		templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/access_denied.html", nil)
   266  	default:
   267  		next(ctx)
   268  	}
   269  }
   270  
   271  // authorizeAPIAccess checks whether the caller is allowed to access the API.
   272  func authorizeAPIAccess(ctx *router.Context, next router.Handler) {
   273  	jsonErr := func(err error, code int) {
   274  		w := ctx.Writer
   275  		if res, err := json.Marshal(map[string]any{"text": err.Error()}); err == nil {
   276  			http.Error(w, string(res), code)
   277  		}
   278  	}
   279  
   280  	ingest := strings.Contains(ctx.Request.URL.RequestURI(), "/auth_service/api/v1/importer/ingest_tarball/")
   281  
   282  	if auth.CurrentIdentity(ctx.Request.Context()) == identity.AnonymousIdentity {
   283  		jsonErr(errors.New("anonymous identity"), http.StatusForbidden)
   284  		return
   285  	}
   286  
   287  	if !ingest {
   288  		switch yes, err := auth.IsMember(ctx.Request.Context(), model.TrustedServicesGroup, model.AdminGroup); {
   289  		case err != nil:
   290  			jsonErr(errors.New("failed to check group membership"), http.StatusInternalServerError)
   291  		case !yes:
   292  			jsonErr(fmt.Errorf("%s is not a member of %s or %s",
   293  				auth.CurrentIdentity(ctx.Request.Context()),
   294  				model.TrustedServicesGroup, model.AdminGroup),
   295  				http.StatusForbidden)
   296  		default:
   297  			next(ctx)
   298  		}
   299  	} else {
   300  		next(ctx)
   301  	}
   302  }
   303  
   304  // replyError renders an HTML page with an error message.
   305  //
   306  // Also logs the internal error in the server logs.
   307  func replyError(ctx *router.Context, err error, message string, code int) {
   308  	logging.Errorf(ctx.Request.Context(), "%s: %s", message, err)
   309  	ctx.Writer.WriteHeader(code)
   310  	templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/error.html", templates.Args{
   311  		"SimpleHeader": true,
   312  		"Message":      message,
   313  	})
   314  }
   315  
   316  // adaptGrpcErr knows how to convert gRPC-style errors to ugly looking HTTP
   317  // error pages with appropriate HTTP status codes.
   318  //
   319  // Recognizes either real gRPC errors (produced with status.Errorf) or
   320  // grpc-tagged errors produced via grpcutil.
   321  func adaptGrpcErr(h func(*router.Context) error) router.Handler {
   322  	return func(ctx *router.Context) {
   323  		err := grpcutil.GRPCifyAndLogErr(ctx.Request.Context(), h(ctx))
   324  		if code := status.Code(err); code != codes.OK {
   325  			http.Error(ctx.Writer, status.Convert(err).Message(), grpcutil.CodeStatus(code))
   326  		}
   327  	}
   328  }