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 }