diff --git a/cmd/server/assets/login/_loginscripts.html b/cmd/server/assets/login/_loginscripts.html index f5f3c56e7..96cc6f7f7 100644 --- a/cmd/server/assets/login/_loginscripts.html +++ b/cmd/server/assets/login/_loginscripts.html @@ -1,6 +1,5 @@ {{define "loginscripts"}} diff --git a/cmd/server/assets/login/verify-email-check.html b/cmd/server/assets/login/verify-email-check.html index 5c1e5b1ff..6637c72e8 100644 --- a/cmd/server/assets/login/verify-email-check.html +++ b/cmd/server/assets/login/verify-email-check.html @@ -38,12 +38,13 @@ } firebase.auth().applyActionCode(code) - .then(function(resp) { - window.location.assign("/login/manage-account?mode=verifyEmail"); - }).catch(function(error) { - flash.error("Invalid email verification code. " - + "The code may be malformed, expired, or has already been used."); - }); + .then(function(resp) { + window.location.assign("/"); + }).catch(function(error) { + flash.clear(); + flash.error("Invalid email verification code. " + + "The code may be malformed, expired, or has already been used."); + }); }); function getUrlVars() { diff --git a/cmd/server/assets/login/verify-email.html b/cmd/server/assets/login/verify-email.html index 13e71da4e..5f7123a9e 100644 --- a/cmd/server/assets/login/verify-email.html +++ b/cmd/server/assets/login/verify-email.html @@ -32,12 +32,12 @@ {{end}} -

Email address ownership for {{.currentUser.Email}} is not confirmed. -

+

Email address ownership for {{.currentUser.Email}} is not confirmed.

-
+ {{ .csrfField }} - + Click to send an email containing a verification link. @@ -56,10 +56,12 @@ + {{if .firebase}} + {{end}} diff --git a/cmd/server/assets/users/show.html b/cmd/server/assets/users/show.html index 33c186b91..497307cb2 100644 --- a/cmd/server/assets/users/show.html +++ b/cmd/server/assets/users/show.html @@ -8,7 +8,6 @@ {{template "head" .}} - {{template "firebase" .}} @@ -43,11 +42,7 @@

{{$user.Name}}

{{$user.CanAdminRealm $currentRealm.ID}} - - {{ .csrfField }} - - - + Send password reset @@ -111,25 +106,6 @@

{{$user.Name}}

} {{end}} - - {{if .firebase}} - - {{end}} {{end}} diff --git a/cmd/server/main.go b/cmd/server/main.go index 8aa4662e6..ac27d3e42 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -21,6 +21,7 @@ import ( "os" "strconv" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/internal/routes" "github.com/google/exposure-notifications-verification-server/pkg/buildinfo" "github.com/google/exposure-notifications-verification-server/pkg/cache" @@ -106,7 +107,13 @@ func realMain(ctx context.Context) error { } defer limiterStore.Close(ctx) - mux, err := routes.Server(ctx, cfg, db, cacher, certificateSigner, limiterStore) + // Setup auth provider + authProvider, err := auth.NewFirebase(ctx, cfg.FirebaseConfig()) + if err != nil { + return fmt.Errorf("failed to create firebase auth provider: %w", err) + } + + mux, err := routes.Server(ctx, cfg, db, authProvider, cacher, certificateSigner, limiterStore) if err != nil { return fmt.Errorf("failed to setup routes: %w", err) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 000000000..904513eae --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,93 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package auth exposes interfaces for various auth methods. +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/gorilla/sessions" +) + +var ( + ErrSessionMissing = fmt.Errorf("session is missing") +) + +// InviteUserEmailFunc sends email with the given inviteLink. +type InviteUserEmailFunc func(ctx context.Context, inviteLink string) error + +// ResetPasswordEmailFunc is an email composer function. +type ResetPasswordEmailFunc func(ctx context.Context, resetLink string) error + +// EmailVerificationEmailFunc is an email composer function. +type EmailVerificationEmailFunc func(ctx context.Context, verifyLink string) error + +// Provider is a generic authentication provider interface. +type Provider interface { + // StoreSession stores the session in the values. + StoreSession(context.Context, *sessions.Session, *SessionInfo) error + + // CheckRevoked checks if the auth has been revoked. It returns an error if + // the auth does not exist or if the auth has been revoked. + CheckRevoked(context.Context, *sessions.Session) error + + // ClearSession removes any information about this auth from the session. + ClearSession(context.Context, *sessions.Session) + + // CreateUser creates a user in the auth provider. If pass is "", the provider + // creates and uses a random password. + CreateUser(ctx context.Context, name, email, pass string, composer InviteUserEmailFunc) (bool, error) + + // SendResetPasswordEmail resets the given user's password. If the user does not exist, + // the underlying provider determines whether itls an error or perhaps upserts + // the account. + SendResetPasswordEmail(ctx context.Context, email string, composer ResetPasswordEmailFunc) error + + // ChangePassword changes the users password. The additional authentication + // information is provider-specific. + ChangePassword(ctx context.Context, newPassword string, data interface{}) error + + // VerifyPasswordResetCode verifies the code is valid. It returns the email of + // the user for which the code belongs. + VerifyPasswordResetCode(ctx context.Context, code string) (string, error) + + // SendEmailVerificationEmail sends the email verification email for the + // currently authenticated user. Data is arbitrary additional data that the + // provider might need (like user ID) to send the verification. + SendEmailVerificationEmail(ctx context.Context, email string, data interface{}, composer EmailVerificationEmailFunc) error + + // EmailAddress extracts the email address for this auth provider from the + // session. It returns an error if the session does not exist. + EmailAddress(context.Context, *sessions.Session) (string, error) + + // EmailVerified returns true if the current user is verified, false + // otherwise. + EmailVerified(context.Context, *sessions.Session) (bool, error) + + // MFAEnabled returns true if MFA is enabled, false otherwise. + MFAEnabled(context.Context, *sessions.Session) (bool, error) +} + +// SessionInfo is a generic struct used to store session information. Not all +// providers use all fields. +type SessionInfo struct { + // IDToken is a unique string or ID. It is usually a JWT token. + IDToken string + + // TTL is the session duration. + TTL time.Duration +} diff --git a/internal/auth/firebase.go b/internal/auth/firebase.go new file mode 100644 index 000000000..1e2c66641 --- /dev/null +++ b/internal/auth/firebase.go @@ -0,0 +1,361 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "fmt" + + firebaseinternal "github.com/google/exposure-notifications-verification-server/internal/firebase" + "github.com/sethvargo/go-password/password" + + firebase "firebase.google.com/go" + "firebase.google.com/go/auth" + "github.com/gorilla/sessions" +) + +const ( + sessionKeyFirebaseCookie = sessionKey("firebaseCookie") +) + +type firebaseAuth struct { + firebaseAuth *auth.Client + firebaseInternal *firebaseinternal.Client +} + +// NewFirebase creates a new auth provider for firebase. +func NewFirebase(ctx context.Context, config *firebase.Config) (Provider, error) { + app, err := firebase.NewApp(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to create firebase app: %w", err) + } + + auth, err := app.Auth(ctx) + if err != nil { + return nil, fmt.Errorf("failed to configure firebase auth: %w", err) + } + + internal, err := firebaseinternal.New(ctx) + if err != nil { + return nil, fmt.Errorf("failed to configure firebase client: %w", err) + } + + return &firebaseAuth{ + firebaseAuth: auth, + firebaseInternal: internal, + }, nil +} + +// CheckRevoked checks if the users auth has been revoked. +func (f *firebaseAuth) CheckRevoked(ctx context.Context, session *sessions.Session) error { + raw, err := sessionGet(session, sessionKeyFirebaseCookie) + if err != nil { + f.ClearSession(ctx, session) + return err + } + + cookie, ok := raw.(string) + if !ok || cookie == "" { + f.ClearSession(ctx, session) + return ErrSessionMissing + } + + if _, err := f.firebaseAuth.VerifySessionCookieAndCheckRevoked(ctx, cookie); err != nil { + f.ClearSession(ctx, session) + return err + } + return nil +} + +// StoreSession stores information about the session. +func (f *firebaseAuth) StoreSession(ctx context.Context, session *sessions.Session, i *SessionInfo) error { + // Convert ID token to long-lived cookie + cookie, err := f.firebaseAuth.SessionCookie(ctx, i.IDToken, i.TTL) + if err != nil { + f.ClearSession(ctx, session) + return err + } + + // Set cookie + if err := sessionSet(session, sessionKeyFirebaseCookie, cookie); err != nil { + f.ClearSession(ctx, session) + return err + } + + return nil +} + +// ClearSession removes any session information for this auth. +func (f *firebaseAuth) ClearSession(ctx context.Context, session *sessions.Session) { + sessionClear(session, sessionKeyFirebaseCookie) +} + +// CreateUser creates a user in the upstream auth system with the given name and +// email. It returns true if the user was created or false if the user already +// exists. +func (f *firebaseAuth) CreateUser(ctx context.Context, name, email, pass string, emailer InviteUserEmailFunc) (bool, error) { + // Attempt to get the user by email. If that returns successfully, it means + // the user exists. + user, err := f.firebaseAuth.GetUserByEmail(ctx, email) + if err != nil && !auth.IsUserNotFound(err) { + return false, fmt.Errorf("failed lookup firebase user: %w", err) + } + + if user == nil { + // If the password is empty, generate one. + if pass == "" { + pass, err = password.Generate(24, 8, 8, false, true) + if err != nil { + return false, fmt.Errorf("failed to generate password: %w", err) + } + } + + // Create the user. + userToCreate := (&auth.UserToCreate{}). + Email(email). + Password(pass). + DisplayName(name) + if _, err = f.firebaseAuth.CreateUser(ctx, userToCreate); err != nil { + return false, fmt.Errorf("failed to create firebase user: %w", err) + } + } + + // Send the welcome email. Use the defined mailer if given, otherwise fallback + // to firebase default. + if emailer != nil { + inviteLink, err := f.passwordResetLink(ctx, email) + if err != nil { + return true, err + } + + if err := emailer(ctx, inviteLink); err != nil { + return true, fmt.Errorf("failed to send new user invitation email: %w", err) + } + } else { + if err := f.firebaseInternal.SendNewUserInvitation(ctx, email); err != nil { + return true, fmt.Errorf("failed to send new user invitation firebase email: %w", err) + } + } + + return true, nil +} + +// IDToken extracts the users IDtoken from the session. +func (f *firebaseAuth) IDToken(ctx context.Context, session *sessions.Session) (string, error) { + data, err := f.loadCookie(ctx, session) + if err != nil { + return "", err + } + return data.IDToken, nil +} + +// EmailAddress extracts the users email from the session. +func (f *firebaseAuth) EmailAddress(ctx context.Context, session *sessions.Session) (string, error) { + data, err := f.loadCookie(ctx, session) + if err != nil { + return "", err + } + return data.Email, nil +} + +// EmailVerified returns true if the current user is verified, false otherwise. +func (f *firebaseAuth) EmailVerified(ctx context.Context, session *sessions.Session) (bool, error) { + data, err := f.loadCookie(ctx, session) + if err != nil { + return false, err + } + return data.EmailVerified, nil +} + +// MFAEnabled returns whether MFA is enabled on the account. +func (f *firebaseAuth) MFAEnabled(ctx context.Context, session *sessions.Session) (bool, error) { + data, err := f.loadCookie(ctx, session) + if err != nil { + return false, err + } + return data.MFAEnabled, nil +} + +// ChangePassword changes the users password. The data must be an oobCode as a +// string. +func (f *firebaseAuth) ChangePassword(ctx context.Context, newPassword string, data interface{}) error { + code, ok := data.(string) + if !ok { + return fmt.Errorf("missing or invalid oobCode") + } + + if _, err := f.firebaseInternal.ChangePasswordWithCode(ctx, code, newPassword); err != nil { + return err + } + return nil +} + +// SendResetPasswordEmail resets the password for the given user. If the user does not +// exist, an error is returned. +func (f *firebaseAuth) SendResetPasswordEmail(ctx context.Context, email string, emailer ResetPasswordEmailFunc) error { + // Send the reset email. Use SMTP emailer if defined, otherwise fallback to + // the firebase default mailer. + if emailer != nil { + resetLink, err := f.passwordResetLink(ctx, email) + if err != nil { + return err + } + + if err := emailer(ctx, resetLink); err != nil { + return fmt.Errorf("failed to send password reset email: %w", err) + } + } else { + if err := f.firebaseInternal.SendNewUserInvitation(ctx, email); err != nil { + return fmt.Errorf("failed to send password reset firebase email: %w", err) + } + } + + return nil +} + +// VerifyPasswordResetCode verifies the password reset code and returns the +// email address for the user. +func (f *firebaseAuth) VerifyPasswordResetCode(ctx context.Context, code string) (string, error) { + email, err := f.firebaseInternal.VerifyPasswordResetCode(ctx, code) + if err != nil { + return "", err + } + return email, nil +} + +// SendEmailVerificationEmail sends an message to the currently authenticated +// user, asking them to verify ownership of the email address. +func (f *firebaseAuth) SendEmailVerificationEmail(ctx context.Context, email string, data interface{}, emailer EmailVerificationEmailFunc) error { + // Send the reset email. Use SMTP emailer if defined, otherwise fallback to + // the firebase default mailer. + if emailer != nil { + verifyLink, err := f.emailVerificationLink(ctx, email) + if err != nil { + return err + } + + if err := emailer(ctx, verifyLink); err != nil { + return fmt.Errorf("failed to send email verification email: %w", err) + } + } else { + if data == nil { + return fmt.Errorf("firebase requires a fresh ID token to send email verifications") + } + idToken, ok := data.(string) + if !ok { + return fmt.Errorf("id token must be a string") + } + + if err := f.firebaseInternal.SendEmailVerification(ctx, idToken); err != nil { + return fmt.Errorf("failed to send email verification firebase email: %w", err) + } + } + + return nil +} + +// passwordResetLink generates and returns the password reset link for the given +// email (user). +func (f *firebaseAuth) passwordResetLink(ctx context.Context, email string) (string, error) { + reset, err := f.firebaseAuth.PasswordResetLink(ctx, email) + if err != nil { + return "", fmt.Errorf("failed to generate password reset link: %w", err) + } + return reset, nil +} + +// emailVerificationLink generates an email verification link for the given +// email. +func (f *firebaseAuth) emailVerificationLink(ctx context.Context, email string) (string, error) { + verify, err := f.firebaseAuth.EmailVerificationLink(ctx, email) + if err != nil { + return "", fmt.Errorf("failed to generate email verification link: %w", err) + } + return verify, nil +} + +type cookieData struct { + IDToken string + Email string + EmailVerified bool + MFAEnabled bool +} + +// dataFromCookie extracts the information from the provided firebase cookie, if +// it exists. +func (f *firebaseAuth) dataFromCookie(ctx context.Context, cookie string) (*cookieData, error) { + token, err := f.firebaseAuth.VerifySessionCookie(ctx, cookie) + if err != nil { + return nil, fmt.Errorf("failed to verify firebase cookie: %w", err) + } + + if token.Claims == nil { + return nil, fmt.Errorf("token claims are empty") + } + + // IDToken + idToken, ok := token.Claims["user_id"].(string) + if !ok { + return nil, fmt.Errorf("token claims for id are not a string") + } + + // Email + email, ok := token.Claims["email"].(string) + if !ok { + return nil, fmt.Errorf("token claims for email are not a string") + } + + // Email verified + emailVerified, ok := token.Claims["email_verified"].(bool) + if !ok { + return nil, fmt.Errorf("token claims for email_verified are not a bool") + } + + // MFA + firebase, ok := token.Claims["firebase"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("token claims for firebase are missing") + } + _, mfaEnabled := firebase["sign_in_second_factor"] + + return &cookieData{ + IDToken: idToken, + Email: email, + EmailVerified: emailVerified, + MFAEnabled: mfaEnabled, + }, nil +} + +// loadCookie loads and parses the firebase cookie from the session. +func (f *firebaseAuth) loadCookie(ctx context.Context, session *sessions.Session) (*cookieData, error) { + raw, err := sessionGet(session, sessionKeyFirebaseCookie) + if err != nil { + f.ClearSession(ctx, session) + return nil, err + } + + cookie, ok := raw.(string) + if !ok || cookie == "" { + f.ClearSession(ctx, session) + return nil, ErrSessionMissing + } + + data, err := f.dataFromCookie(ctx, cookie) + if err != nil { + f.ClearSession(ctx, session) + return nil, err + } + return data, nil +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 000000000..c68077a06 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,64 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "encoding/gob" + + "github.com/gorilla/sessions" +) + +// sessionKey is a custom type for keys in the session values. +type sessionKey string + +// init registers the session key gob. +func init() { + gob.Register(*new(sessionKey)) +} + +// sessionSet sets the value in the session. +func sessionSet(session *sessions.Session, key sessionKey, data interface{}) error { + if session == nil { + return ErrSessionMissing + } + + if session.Values == nil { + session.Values = make(map[interface{}]interface{}) + } + + session.Values[key] = data + return nil +} + +// sessionGet retrieves the given key as a string from the session. +func sessionGet(session *sessions.Session, key sessionKey) (interface{}, error) { + if session == nil || session.Values == nil { + return "", ErrSessionMissing + } + + v, ok := session.Values[key] + if !ok || v == nil { + return "", ErrSessionMissing + } + return v, nil +} + +// sessionClear clears the value from the session. +func sessionClear(session *sessions.Session, key sessionKey) { + if session == nil { + return + } + delete(session.Values, key) +} diff --git a/internal/firebase/verify_email.go b/internal/firebase/verify_email.go new file mode 100644 index 000000000..6b4670a0c --- /dev/null +++ b/internal/firebase/verify_email.go @@ -0,0 +1,76 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package firebase is common logic and handling around firebase. +package firebase + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +type sendEmailVerificationRequest struct { + RequestType string `json:"requestType"` + IDToken string `json:"idToken"` +} + +// SendEmailVerification sends a message to verify the email address. +// +// See: https://firebase.google.com/docs/reference/rest/auth#section-send-email-verification +func (c *Client) SendEmailVerification(ctx context.Context, idToken string) error { + r := &sendEmailVerificationRequest{ + RequestType: "VERIFY_EMAIL", + IDToken: idToken, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(r); err != nil { + return fmt.Errorf("failed to create json body: %w", err) + } + + u := c.buildURL("/v1/accounts:sendOobCode") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &body) + if err != nil { + return fmt.Errorf("failed to build request: %w", err) + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send password reset email: %w", err) + } + defer resp.Body.Close() + + if status := resp.StatusCode; status != http.StatusOK { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("response was %d, but failed to read body: %w", status, err) + } + + // Try to unmarshal the error message. Firebase uses these as enum values to expand on the code. + var m map[string]ErrorDetails + if err := json.Unmarshal(b, &m); err == nil { + d := m["error"] + return &d + } + return fmt.Errorf("failure %d: %s", status, string(b)) + } + + return nil +} diff --git a/internal/routes/server.go b/internal/routes/server.go index f94b622dd..a56d5a690 100644 --- a/internal/routes/server.go +++ b/internal/routes/server.go @@ -20,7 +20,7 @@ import ( "net/http" "path/filepath" - iFB "github.com/google/exposure-notifications-verification-server/internal/firebase" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/controller" @@ -43,13 +43,20 @@ import ( "github.com/google/exposure-notifications-server/pkg/keys" - firebase "firebase.google.com/go" "github.com/gorilla/mux" "github.com/gorilla/sessions" ) // Server defines routes for the UI server. -func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database, cacher cache.Cacher, certificateSigner keys.KeyManager, limiterStore limiter.Store) (http.Handler, error) { +func Server( + ctx context.Context, + cfg *config.ServerConfig, + db *database.Database, + authProvider auth.Provider, + cacher cache.Cacher, + certificateSigner keys.KeyManager, + limiterStore limiter.Store, +) (http.Handler, error) { // Setup sessions sessions := sessions.NewCookieStore(cfg.CookieKeys.AsBytes()...) sessions.Options.Path = "/" @@ -58,20 +65,6 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database sessions.Options.Secure = !cfg.DevMode sessions.Options.SameSite = http.SameSiteStrictMode - // Setup firebase - app, err := firebase.NewApp(ctx, cfg.FirebaseConfig()) - if err != nil { - return nil, fmt.Errorf("failed to setup firebase: %w", err) - } - auth, err := app.Auth(ctx) - if err != nil { - return nil, fmt.Errorf("failed to configure firebase: %w", err) - } - firebaseInternal, err := iFB.New(ctx) - if err != nil { - return nil, fmt.Errorf("failed to configure internal firebase client: %w", err) - } - // Create the router r := mux.NewRouter() @@ -113,13 +106,13 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database r.Use(currentPath) // Create common middleware - requireAuth := middleware.RequireAuth(ctx, cacher, auth, db, h, cfg.SessionIdleTimeout, cfg.SessionDuration) - requireVerified := middleware.RequireVerified(ctx, auth, db, h, cfg.SessionDuration) + requireAuth := middleware.RequireAuth(ctx, cacher, authProvider, db, h, cfg.SessionIdleTimeout, cfg.SessionDuration) + requireVerified := middleware.RequireVerified(ctx, authProvider, db, h, cfg.SessionDuration) requireAdmin := middleware.RequireRealmAdmin(ctx, h) loadCurrentRealm := middleware.LoadCurrentRealm(ctx, cacher, db, h) requireRealm := middleware.RequireRealm(ctx, h) requireSystemAdmin := middleware.RequireAdmin(ctx, h) - requireMFA := middleware.RequireMFA(ctx, h) + requireMFA := middleware.RequireMFA(ctx, authProvider, h) processFirewall := middleware.ProcessFirewall(ctx, h, "server") rateLimit := httplimiter.Handle @@ -141,7 +134,7 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database } { - loginController := login.New(ctx, firebaseInternal, auth, cfg, db, h) + loginController := login.New(ctx, authProvider, cfg, db, h) { sub := r.PathPrefix("").Subrouter() sub.Use(rateLimit) @@ -278,7 +271,7 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database userSub.Use(requireMFA) userSub.Use(rateLimit) - userController := user.New(ctx, firebaseInternal, auth, cacher, cfg, db, h) + userController := user.New(ctx, authProvider, cacher, cfg, db, h) userSub.Handle("", userController.HandleIndex()).Methods("GET") userSub.Handle("", userController.HandleIndex()). Queries("offset", "{[0-9]*}", "email", "").Methods("GET") @@ -290,7 +283,7 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database userSub.Handle("/{id}", userController.HandleShow()).Methods("GET") userSub.Handle("/{id}", userController.HandleUpdate()).Methods("PATCH") userSub.Handle("/{id}", userController.HandleDelete()).Methods("DELETE") - userSub.Handle("/{id}", userController.HandleResetPassword()).Methods("POST") + userSub.Handle("/{id}/reset-password", userController.HandleResetPassword()).Methods("POST") } // realms @@ -342,11 +335,10 @@ func Server(ctx context.Context, cfg *config.ServerConfig, db *database.Database adminSub := r.PathPrefix("/admin").Subrouter() adminSub.Use(requireAuth) adminSub.Use(loadCurrentRealm) - adminSub.Use(requireVerified) adminSub.Use(requireSystemAdmin) adminSub.Use(rateLimit) - adminController := admin.New(ctx, cfg, cacher, db, auth, h) + adminController := admin.New(ctx, cfg, cacher, db, authProvider, h) adminSub.Handle("", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods("GET") adminSub.Handle("/realms", adminController.HandleRealmsIndex()).Methods("GET") adminSub.Handle("/realms", adminController.HandleRealmsCreate()).Methods("POST") diff --git a/pkg/controller/admin/admin.go b/pkg/controller/admin/admin.go index 6d036b2f9..6e7a54d89 100644 --- a/pkg/controller/admin/admin.go +++ b/pkg/controller/admin/admin.go @@ -18,7 +18,7 @@ package admin import ( "context" - "firebase.google.com/go/auth" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/database" @@ -33,19 +33,26 @@ type Controller struct { cacher cache.Cacher config *config.ServerConfig db *database.Database - firebaseAuth *auth.Client + authProvider auth.Provider h *render.Renderer logger *zap.SugaredLogger } -func New(ctx context.Context, config *config.ServerConfig, cacher cache.Cacher, db *database.Database, firebaseAuth *auth.Client, h *render.Renderer) *Controller { +func New( + ctx context.Context, + config *config.ServerConfig, + cacher cache.Cacher, + db *database.Database, + authProvider auth.Provider, + h *render.Renderer, +) *Controller { logger := logging.FromContext(ctx).Named("admin") return &Controller{ config: config, cacher: cacher, db: db, - firebaseAuth: firebaseAuth, + authProvider: authProvider, h: h, logger: logger, } diff --git a/pkg/controller/admin/users.go b/pkg/controller/admin/users.go index 9efdf25bc..6e0186ec6 100644 --- a/pkg/controller/admin/users.go +++ b/pkg/controller/admin/users.go @@ -16,9 +16,11 @@ package admin import ( "context" + "fmt" "net/http" "strings" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" "github.com/gorilla/mux" @@ -108,17 +110,15 @@ func (c *Controller) HandleUsersCreate() http.Handler { return } - created, err := user.CreateFirebaseUser(ctx, c.firebaseAuth) + inviteComposer, err := c.inviteComposer(ctx, email) if err != nil { - flash.Alert("Failed to create user: %v", err) - c.renderNewUser(ctx, w, user) + controller.InternalError(w, r, c.h, err) + return } - // Once the Go SDK supports server-side password reset emails, we can drop - // this. - if created { - flash.Alert(`Successfully created user %v with a random password. `+ - `Have them set a password using the "Forgot Password" function.`, user.Email) + if _, err := c.authProvider.CreateUser(ctx, name, email, "", inviteComposer); err != nil { + flash.Alert("Failed to create user: %v", err) + c.renderNewUser(ctx, w, user) } flash.Alert("Successfully created system admin '%v'", user.Name) @@ -179,3 +179,43 @@ func (c *Controller) HandleUsersDelete() http.Handler { controller.Back(w, r, c.h) }) } + +// inviteComposer returns an email composer function that invites a user using +// the system email config. +func (c *Controller) inviteComposer(ctx context.Context, email string) (auth.InviteUserEmailFunc, error) { + // Figure out email sending - since this is a system admin, only the system + // credentials can be used. + emailConfig, err := c.db.SystemEmailConfig() + if err != nil { + if database.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + emailer, err := emailConfig.Provider() + if err != nil { + return nil, err + } + + // Return a function that does the actual sending. + return func(ctx context.Context, inviteLink string) error { + // Render the message invitation. + message, err := c.h.RenderEmail("email/invite", map[string]interface{}{ + "ToEmail": email, + "FromEmail": emailer.From, + "InviteLink": inviteLink, + "RealmName": "System Admin", + }) + if err != nil { + return fmt.Errorf("failed to render invite template: %w", err) + } + + // Send the message. + if err := emailer.SendEmail(ctx, email, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + return nil + }, nil +} diff --git a/pkg/controller/email.go b/pkg/controller/email.go new file mode 100644 index 000000000..63b429945 --- /dev/null +++ b/pkg/controller/email.go @@ -0,0 +1,138 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + + "github.com/google/exposure-notifications-verification-server/internal/auth" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/render" +) + +// SendInviteEmailFunc returns a function capable of sending a new user invitation. +func SendInviteEmailFunc(ctx context.Context, db *database.Database, h *render.Renderer, email string) (auth.InviteUserEmailFunc, error) { + // Lookup the realm to get the email provider + realm := RealmFromContext(ctx) + if realm == nil { + return nil, nil + } + + // Lookup the email provider + emailer, err := realm.EmailProvider(db) + if err != nil { + if database.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to create email provider: %w", err) + } + + // Return a function that does the actual sending. + return func(ctx context.Context, inviteLink string) error { + // Render the message invitation. + message, err := h.RenderEmail("email/invite", map[string]interface{}{ + "ToEmail": email, + "FromEmail": emailer.From, + "InviteLink": inviteLink, + "RealmName": realm.Name, + }) + if err != nil { + return fmt.Errorf("failed to render invite template: %w", err) + } + + // Send the message. + if err := emailer.SendEmail(ctx, email, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + return nil + }, nil +} + +// SendPasswordResetEmailFunc returns a function capable of sending a password +// reset for the given user. +func SendPasswordResetEmailFunc(ctx context.Context, db *database.Database, h *render.Renderer, email string) (auth.ResetPasswordEmailFunc, error) { + // Lookup the realm to get the email provider + realm := RealmFromContext(ctx) + if realm == nil { + return nil, nil + } + + // Lookup the email provider + emailer, err := realm.EmailProvider(db) + if err != nil { + if database.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to create email provider: %w", err) + } + + return func(ctx context.Context, resetLink string) error { + // Render the reset email. + message, err := h.RenderEmail("email/passwordresetemail", map[string]interface{}{ + "ToEmail": email, + "FromEmail": emailer.From, + "ResetLink": resetLink, + "RealmName": realm.Name, + }) + if err != nil { + return fmt.Errorf("failed to render password reset template: %w", err) + } + + // Send the message. + if err := emailer.SendEmail(ctx, email, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + return nil + }, nil +} + +// SendEmailVerificationEmailFunc returns a function capable of sending an email +// verification email. +func SendEmailVerificationEmailFunc(ctx context.Context, db *database.Database, h *render.Renderer, email string) (auth.EmailVerificationEmailFunc, error) { + // Lookup the realm to get the email provider + realm := RealmFromContext(ctx) + if realm == nil { + return nil, nil + } + + // Lookup the email provider + emailer, err := realm.EmailProvider(db) + if err != nil { + if database.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to create email provider: %w", err) + } + + return func(ctx context.Context, verifyLink string) error { + // Render the reset email. + message, err := h.RenderEmail("email/verifyemail", map[string]interface{}{ + "ToEmail": email, + "FromEmail": emailer.From, + "VerifyLink": verifyLink, + "RealmName": realm.Name, + }) + if err != nil { + return fmt.Errorf("failed to render password reset template: %w", err) + } + + // Send the message. + if err := emailer.SendEmail(ctx, email, message); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + return nil + }, nil +} diff --git a/pkg/controller/email_compose.go b/pkg/controller/email_compose.go deleted file mode 100644 index 29e4c7445..000000000 --- a/pkg/controller/email_compose.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2020 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package controller - -import ( - "context" - "fmt" - - "firebase.google.com/go/auth" - "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/email" - "github.com/google/exposure-notifications-verification-server/pkg/render" -) - -type ComposeFn func(provider email.Provider, realm *database.Realm, toEmail string) ([]byte, error) - -// ComposeInviteEmail uses the renderer and auth client to generate a password reset link -// and emit an invite email -func ComposeInviteEmail( - ctx context.Context, - h *render.Renderer, - auth *auth.Client, toEmail, fromEmail, realmName string) ([]byte, error) { - inviteLink, err := auth.PasswordResetLink(ctx, toEmail) - if err != nil { - return nil, fmt.Errorf("failed generating reset link: %w", err) - } - - // Compose message - message, err := h.RenderEmail("email/invite", - struct { - ToEmail string - FromEmail string - InviteLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: fromEmail, - InviteLink: inviteLink, - RealmName: realmName, - }) - if err != nil { - return nil, fmt.Errorf("failed rendering invite template: %w", err) - } - return message, nil -} - -// ComposeEmailVerifyEmail uses the renderer and auth client to generate an email to verify -// the user's email address. -func ComposeEmailVerifyEmail( - ctx context.Context, - h *render.Renderer, - auth *auth.Client, toEmail, fromEmail, realmName string) ([]byte, error) { - verifyLink, err := auth.EmailVerificationLink(ctx, toEmail) - if err != nil { - return nil, fmt.Errorf("failed generating verification link: %w", err) - } - - // Compose message - message, err := h.RenderEmail("email/verifyemail", - struct { - ToEmail string - FromEmail string - VerifyLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: fromEmail, - VerifyLink: verifyLink, - RealmName: realmName, - }) - if err != nil { - return nil, fmt.Errorf("failed rendering email verification template: %w", err) - } - return message, nil -} - -// ComposePasswordResetEmail uses the renderer and auth client to generate an email for resetting -// a user password -func ComposePasswordResetEmail( - ctx context.Context, - h *render.Renderer, - auth *auth.Client, toEmail, fromEmail string) ([]byte, error) { - resetLink, err := auth.PasswordResetLink(ctx, toEmail) - if err != nil { - return nil, fmt.Errorf("failed generating password reset link: %w", err) - } - - // Compose message - message, err := h.RenderEmail("email/passwordresetemail", - struct { - ToEmail string - FromEmail string - ResetLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: fromEmail, - ResetLink: resetLink, - }) - if err != nil { - return nil, fmt.Errorf("failed rendering password reset template: %w", err) - } - return message, nil -} - -func SendRealmEmail(ctx context.Context, db *database.Database, compose ComposeFn, toEmail string) (bool, error) { - realm := RealmFromContext(ctx) - if realm == nil { - return false, nil - } - - emailer, err := realm.EmailProvider(db) - if err != nil { - if database.IsNotFound(err) { - return false, nil - } - return false, fmt.Errorf("failed creating email provider: %w", err) - } - - message, err := compose(emailer, realm, toEmail) - if err != nil { - return false, fmt.Errorf("failed composing email verification: %w", err) - } - - if err := emailer.SendEmail(ctx, toEmail, message); err != nil { - return false, fmt.Errorf("failed sending email verification: %w", err) - } - - return true, nil -} diff --git a/pkg/controller/login/controller.go b/pkg/controller/login/controller.go index 048842c3d..e48a1acdc 100644 --- a/pkg/controller/login/controller.go +++ b/pkg/controller/login/controller.go @@ -18,42 +18,38 @@ package login import ( "context" - "github.com/google/exposure-notifications-verification-server/internal/firebase" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/database" "github.com/google/exposure-notifications-verification-server/pkg/render" "github.com/google/exposure-notifications-server/pkg/logging" - "firebase.google.com/go/auth" "go.uber.org/zap" ) type Controller struct { - firebaseInternal *firebase.Client - client *auth.Client - config *config.ServerConfig - db *database.Database - h *render.Renderer - logger *zap.SugaredLogger + authProvider auth.Provider + config *config.ServerConfig + db *database.Database + h *render.Renderer + logger *zap.SugaredLogger } // New creates a new login controller. func New( ctx context.Context, - firebaseInternal *firebase.Client, - client *auth.Client, + authProvider auth.Provider, config *config.ServerConfig, db *database.Database, h *render.Renderer) *Controller { logger := logging.FromContext(ctx).Named("login") return &Controller{ - firebaseInternal: firebaseInternal, - client: client, - config: config, - db: db, - h: h, - logger: logger, + authProvider: authProvider, + config: config, + db: db, + h: h, + logger: logger, } } diff --git a/pkg/controller/login/login.go b/pkg/controller/login/login.go index 695a29d6d..84259c97a 100644 --- a/pkg/controller/login/login.go +++ b/pkg/controller/login/login.go @@ -31,7 +31,7 @@ func (c *Controller) HandleLogin() http.Handler { // cookie from the session, and kick them back here. session := controller.SessionFromContext(ctx) if session != nil { - if c := controller.FirebaseCookieFromSession(session); c != "" { + if err := c.authProvider.CheckRevoked(ctx, session); err == nil { http.Redirect(w, r, "/home", http.StatusSeeOther) return } diff --git a/pkg/controller/login/reset_password.go b/pkg/controller/login/reset_password.go index f5071f388..9fa980358 100644 --- a/pkg/controller/login/reset_password.go +++ b/pkg/controller/login/reset_password.go @@ -17,106 +17,75 @@ package login import ( "context" - "fmt" "net/http" - "strings" "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/controller/flash" "github.com/google/exposure-notifications-verification-server/pkg/database" - "go.opencensus.io/stats" ) func (c *Controller) HandleShowResetPassword() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - c.renderResetPassword(ctx, w, nil, "", false) + c.renderResetPassword(ctx, w, "") }) } -func (c *Controller) renderResetPassword( - ctx context.Context, w http.ResponseWriter, - flash *flash.Flash, email string, reset bool) { +func (c *Controller) renderResetPassword(ctx context.Context, w http.ResponseWriter, email string) { m := controller.TemplateMapFromContext(ctx) - m["flash"] = flash m["email"] = email - if reset { - m["firebase"] = c.config.Firebase - } c.h.RenderHTML(w, "login/reset-password", m) } func (c *Controller) HandleSubmitResetPassword() http.Handler { type FormData struct { - Email string `form:"email"` + Email string `form:"email,required"` } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + session := controller.SessionFromContext(ctx) - flash := flash.New(session.Values) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + flash := controller.Flash(session) var form FormData if err := controller.BindForm(w, r, &form); err != nil { - flash.Error("Password reset failed. %v", err) - c.renderResetPassword(ctx, w, flash, "", false) + flash.Error("Failed to reset password: %v", err) + c.renderResetPassword(ctx, w, "") return } - email := strings.TrimSpace(form.Email) - // Ensure that if we have a user, they have auth - user, err := c.db.FindUserByEmail(email) + // Does the user exist? + user, err := c.db.FindUserByEmail(form.Email) if err != nil { if database.IsNotFound(err) { - flash.Alert("Password reset email sent.") + flash.Error("No such user exists.") + c.renderResetPassword(ctx, w, form.Email) + return } - c.renderResetPassword(ctx, w, flash, email, false) - return - } - if created, _ := user.CreateFirebaseUser(ctx, c.client); created { - stats.Record(ctx, controller.MFirebaseRecreates.M(1)) + controller.InternalError(w, r, c.h, err) + return } - sent, err := c.sendResetFromSystemEmailer(ctx, email) + // Build the emailer. + resetComposer, err := controller.SendPasswordResetEmailFunc(ctx, c.db, c.h, user.Email) if err != nil { - c.logger.Warnw("failed sending password reset", "error", err) + controller.InternalError(w, r, c.h, err) + return } - if !sent { - // fallback to firebase - c.renderResetPassword(ctx, w, flash, email, true) + + // Reset the password. + if err := c.authProvider.SendResetPasswordEmail(ctx, user.Email, resetComposer); err != nil { + flash.Error("Failed to reset password: %v", err) + controller.Back(w, r, c.h) return } flash.Alert("Password reset email sent.") - c.renderResetPassword(ctx, w, flash, email, false) + c.renderResetPassword(ctx, w, user.Email) }) } - -func (c *Controller) sendResetFromSystemEmailer(ctx context.Context, toEmail string) (bool, error) { - // Send email with system email config - - emailConfig, err := c.db.SystemEmailConfig() - if err != nil { - if database.IsNotFound(err) { - return false, nil - } - return false, fmt.Errorf("failed to get email config for system: %w", err) - } - - emailer, err := emailConfig.Provider() - if err != nil { - return false, fmt.Errorf("failed to get emailer for realm: %w", err) - } - - message, err := controller.ComposePasswordResetEmail(ctx, c.h, c.client, toEmail, emailer.From()) - if err != nil { - return false, fmt.Errorf("failed composing password reset email: %w", err) - } - - if err := emailer.SendEmail(ctx, toEmail, message); err != nil { - return false, fmt.Errorf("failed sending password reset email: %w", err) - } - - return true, nil -} diff --git a/pkg/controller/login/select_password.go b/pkg/controller/login/select_password.go index d9b38c2f9..3ebac955d 100644 --- a/pkg/controller/login/select_password.go +++ b/pkg/controller/login/select_password.go @@ -17,14 +17,12 @@ package login import ( "context" - "errors" "fmt" "net/http" "strings" "time" "unicode" - "github.com/google/exposure-notifications-verification-server/internal/firebase" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/controller/flash" ) @@ -37,35 +35,27 @@ func (c *Controller) HandleShowSelectNewPassword() http.Handler { code := r.FormValue("oobCode") if code == "" { - flash.Error("No oobCode.") - c.renderShowSelectPassword(ctx, w, "", code, true, flash) + flash.Error("Missing password reset token.") + c.renderShowSelectPassword(ctx, w, "", code, true) return } - email, err := c.firebaseInternal.VerifyPasswordResetCode(ctx, code) + email, err := c.authProvider.VerifyPasswordResetCode(ctx, code) if err != nil { - if errors.Is(err, firebase.ErrInvalidOOBCode) || errors.Is(err, firebase.ErrExpiredOOBCode) { - flash.Error("The action code is invalid. This can happen if the code is malformed, expired, or has already been used.") - c.renderShowSelectPassword(ctx, w, "", code, true, flash) - } else { - flash.Error("Error checking code. %v", err) - c.renderShowSelectPassword(ctx, w, "", code, false, flash) - } + flash.Error("Failed to verify password reset token: %v", err) + c.renderShowSelectPassword(ctx, w, "", code, false) return } - c.renderShowSelectPassword(ctx, w, email, code, false, flash) + c.renderShowSelectPassword(ctx, w, email, code, false) }) } -func (c *Controller) renderShowSelectPassword( - ctx context.Context, w http.ResponseWriter, - email, code string, oobCodeInvalid bool, flash *flash.Flash) { +func (c *Controller) renderShowSelectPassword(ctx context.Context, w http.ResponseWriter, email, code string, codeInvalid bool) { m := controller.TemplateMapFromContext(ctx) m["email"] = email m["code"] = code - m["flash"] = flash - m["codeInvalid"] = oobCodeInvalid + m["codeInvalid"] = codeInvalid m["requirements"] = &c.config.PasswordRequirements c.h.RenderHTML(w, "login/select-password", m) } @@ -81,36 +71,35 @@ func (c *Controller) HandleSubmitNewPassword() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() session := controller.SessionFromContext(ctx) - flash := flash.New(session.Values) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + flash := controller.Flash(session) code := r.FormValue("oobCode") var form FormData if err := controller.BindForm(w, r, &form); err != nil { flash.Error("Select password failed: %v", err) - c.renderShowSelectPassword(ctx, w, "", code, false, flash) + c.renderShowSelectPassword(ctx, w, "", code, false) return } email := strings.TrimSpace(form.Email) if err := c.validateComplexity(form.Password); err != nil { flash.Error("Select password failed: %v", err) - c.renderShowSelectPassword(ctx, w, email, code, false, flash) + c.renderShowSelectPassword(ctx, w, email, code, false) return } - if _, err := c.firebaseInternal.ChangePasswordWithCode(ctx, code, form.Password); err != nil { - if errors.Is(err, firebase.ErrInvalidOOBCode) || errors.Is(err, firebase.ErrExpiredOOBCode) { - flash.Error("The action code is invalid. This can happen if the code is malformed, expired, or has already been used.") - c.renderShowSelectPassword(ctx, w, email, code, true, flash) - } else { - flash.Error("Select password failed. %v", err) - c.renderShowSelectPassword(ctx, w, email, code, false, flash) - } + if err := c.authProvider.ChangePassword(ctx, form.Password, code); err != nil { + flash.Error("Failed to change password: %v", err) + c.renderShowSelectPassword(ctx, w, email, code, true) return } - if err := c.db.PasswordChanged(email, time.Now()); err != nil { + if err := c.db.PasswordChanged(email, time.Now().UTC()); err != nil { logger.Errorw("failed to mark password change time", "error", err) } diff --git a/pkg/controller/login/session.go b/pkg/controller/login/session.go index 822749469..864bae051 100644 --- a/pkg/controller/login/session.go +++ b/pkg/controller/login/session.go @@ -17,14 +17,14 @@ package login import ( "net/http" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/api" "github.com/google/exposure-notifications-verification-server/pkg/controller" ) func (c *Controller) HandleCreateSession() http.Handler { type FormData struct { - IDToken string `form:"idToken,required"` - FactorCount uint `form:"factorCount"` + IDToken string `form:"idToken,required"` } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -45,25 +45,16 @@ func (c *Controller) HandleCreateSession() http.Handler { return } - // TODO: when the Identity Platform go client supports MFA, switch to directly - // grabbing this from the auth user struct - controller.StoreSessionFactorCount(session, form.FactorCount) - if form.FactorCount > 0 { - controller.StoreSessionMFAPrompted(session, true) - } - - // Get the session cookie from firebase. - ttl := c.config.SessionDuration - cookie, err := c.client.SessionCookie(ctx, form.IDToken, ttl) - if err != nil { + // Create the session cookie. + if err := c.authProvider.StoreSession(ctx, session, &auth.SessionInfo{ + IDToken: form.IDToken, + TTL: c.config.SessionDuration, + }); err != nil { flash.Error("Failed to create session: %v", err) c.h.RenderJSON(w, http.StatusUnauthorized, api.Error(err)) return } - // Set the firebase cookie value in our session. - controller.StoreSessionFirebaseCookie(session, cookie) - c.h.RenderJSON(w, http.StatusOK, nil) }) } diff --git a/pkg/controller/login/verify_email_send.go b/pkg/controller/login/verify_email_send.go index 4d6cc1790..19467372b 100644 --- a/pkg/controller/login/verify_email_send.go +++ b/pkg/controller/login/verify_email_send.go @@ -20,8 +20,6 @@ import ( "net/http" "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/email" ) func (c *Controller) HandleShowVerifyEmail() http.Handler { @@ -37,11 +35,15 @@ func (c *Controller) HandleShowVerifyEmail() http.Handler { // Mark prompted so we only prompt once. controller.StoreSessionEmailVerificationPrompted(session, true) - c.renderEmailVerify(ctx, w, "") + c.renderEmailVerify(ctx, w) }) } func (c *Controller) HandleSubmitVerifyEmail() http.Handler { + type FormData struct { + IDToken string `form:"idToken"` + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -58,30 +60,33 @@ func (c *Controller) HandleSubmitVerifyEmail() http.Handler { return } - compose := func(emailer email.Provider, realm *database.Realm, toEmail string) ([]byte, error) { - return controller.ComposeEmailVerifyEmail(ctx, c.h, c.client, toEmail, emailer.From(), realm.Name) + var form FormData + if err := controller.BindForm(w, r, &form); err != nil { + flash.Error("Failed to verify email: %v", err) + c.renderEmailVerify(ctx, w) + return } - sent, err := controller.SendRealmEmail(ctx, c.db, compose, currentUser.Email) + + // Build the email template. + verifyComposer, err := controller.SendEmailVerificationEmailFunc(ctx, c.db, c.h, currentUser.Email) if err != nil { - c.logger.Warnw("failed sending verification", "error", err) - flash.Error("Failed to send verification email.") - c.renderEmailVerify(ctx, w, "") + controller.InternalError(w, r, c.h, err) return } - if !sent { - // fallback to Firebase if not SMTP found - c.renderEmailVerify(ctx, w, "send") + + // Send an email to verify the users email address. + if err := c.authProvider.SendEmailVerificationEmail(ctx, currentUser.Email, form.IDToken, verifyComposer); err != nil { + controller.InternalError(w, r, c.h, err) return } flash.Alert("Verification email sent.") - c.renderEmailVerify(ctx, w, "sent") + c.renderEmailVerify(ctx, w) }) } -func (c *Controller) renderEmailVerify(ctx context.Context, w http.ResponseWriter, sendInvite string) { +func (c *Controller) renderEmailVerify(ctx context.Context, w http.ResponseWriter) { m := controller.TemplateMapFromContext(ctx) - m["sendInvite"] = sendInvite m["firebase"] = c.config.Firebase c.h.RenderHTML(w, "login/verify-email", m) } diff --git a/pkg/controller/middleware/auth.go b/pkg/controller/middleware/auth.go index ee512ac44..45cebaece 100644 --- a/pkg/controller/middleware/auth.go +++ b/pkg/controller/middleware/auth.go @@ -17,10 +17,10 @@ package middleware import ( "context" - "fmt" "net/http" "time" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" @@ -28,17 +28,16 @@ import ( "github.com/google/exposure-notifications-server/pkg/logging" - "firebase.google.com/go/auth" "github.com/gorilla/mux" ) // RequireAuth requires a user to be logged in. It also ensures that currentUser // is set in the template map. It fetches a user from the session and stores the // full record in the request context. -func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client, db *database.Database, h *render.Renderer, sessionIdleTTL, expiryCheckTTL time.Duration) mux.MiddlewareFunc { +func RequireAuth(ctx context.Context, cacher cache.Cacher, authProvider auth.Provider, db *database.Database, h *render.Renderer, sessionIdleTTL, expiryCheckTTL time.Duration) mux.MiddlewareFunc { logger := logging.FromContext(ctx).Named("middleware.RequireAuth") - cacheTTL := 5 * time.Minute + cacheTTL := 15 * time.Minute return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -66,31 +65,16 @@ func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client } } - firebaseCookie := controller.FirebaseCookieFromSession(session) - if firebaseCookie == "" { - logger.Debugw("firebase cookie not in session") - flash.Error("An error occurred trying to verify your credentials.") - controller.Unauthorized(w, r, h) - return - } - - email, err := EmailFromFirebaseCookie(ctx, fbClient, firebaseCookie) + // Get the email from the auth provider. + email, err := authProvider.EmailAddress(ctx, session) if err != nil { - controller.ClearSessionFirebaseCookie(session) + authProvider.ClearSession(ctx, session) - logger.Debugw("failed to extract email from firebase cookie", "error", err) - flash.Error("Your credentials are invalid. Clear your cookies and try again.") - controller.Unauthorized(w, r, h) - return - } - firebaseUser, err := fbClient.GetUserByEmail(ctx, email) - if err != nil { - logger.Debugw("firebase user does not exist") - controller.ClearSessionFirebaseCookie(session) + logger.Debugw("failed to get email from session", "error", err) + flash.Error("An error occurred trying to verify your credentials.") controller.Unauthorized(w, r, h) return } - ctx = controller.WithFirebaseUser(ctx, firebaseUser) // Load the user by using the cache to alleviate pressure on the database // layer. @@ -102,7 +86,7 @@ func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client if err := cacher.Fetch(ctx, cacheKey, &user, cacheTTL, func() (interface{}, error) { return db.FindUserByEmail(email) }); err != nil { - controller.ClearSessionFirebaseCookie(session) + authProvider.ClearSession(ctx, session) if database.IsNotFound(err) { logger.Debugw("user does not exist") @@ -117,9 +101,11 @@ func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client // Check if the session is still valid. if time.Now().After(user.LastRevokeCheck.Add(expiryCheckTTL)) { - if _, err := fbClient.VerifySessionCookieAndCheckRevoked(ctx, firebaseCookie); err != nil { - logger.Debugw("failed to verify firebase cookie revocation", "error", err) - controller.ClearSessionFirebaseCookie(session) + // Check if the session has been revoked. + if err := authProvider.CheckRevoked(ctx, session); err != nil { + authProvider.ClearSession(ctx, session) + + logger.Debugw("session revoked", "error", err) controller.Unauthorized(w, r, h) return } @@ -132,7 +118,7 @@ func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client } // Update the user in the cache so it has the new revoke check time. - if err := cacher.Write(ctx, cacheKey, &user, 30*time.Second); err != nil { + if err := cacher.Write(ctx, cacheKey, &user, cacheTTL); err != nil { logger.Errorw("failed to cached user revocation check time", "error", err) controller.InternalError(w, r, h, err) return @@ -148,25 +134,6 @@ func RequireAuth(ctx context.Context, cacher cache.Cacher, fbClient *auth.Client } } -// EmailFromFirebaseCookie extracts the user's email address from the provided -// firebase cookie, if it exists. -func EmailFromFirebaseCookie(ctx context.Context, fbClient *auth.Client, cookie string) (string, error) { - token, err := fbClient.VerifySessionCookie(ctx, cookie) - if err != nil { - return "", fmt.Errorf("failed to verify firebase cookie: %w", err) - } - - if token.Claims == nil || token.Claims["email"] == nil { - return "", fmt.Errorf("missing token claims for email") - } - - email, ok := token.Claims["email"].(string) - if !ok { - return "", fmt.Errorf("token claims for email are not a string") - } - return email, nil -} - // RequireAdmin requires the current user is a global administrator. It must // come after RequireAuth so that a user is set on the context. func RequireAdmin(ctx context.Context, h *render.Renderer) mux.MiddlewareFunc { diff --git a/pkg/controller/middleware/emailverified.go b/pkg/controller/middleware/emailverified.go index c09905e3b..923a08f2b 100644 --- a/pkg/controller/middleware/emailverified.go +++ b/pkg/controller/middleware/emailverified.go @@ -20,20 +20,21 @@ import ( "net/http" "time" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" "github.com/google/exposure-notifications-verification-server/pkg/render" "github.com/google/exposure-notifications-server/pkg/logging" - "firebase.google.com/go/auth" "github.com/gorilla/mux" - "github.com/gorilla/sessions" ) // RequireVerified requires a user to have verified their login email. -// MUST first run RequireAuth to populate user. -func RequireVerified(ctx context.Context, client *auth.Client, db *database.Database, h *render.Renderer, ttl time.Duration) mux.MiddlewareFunc { +// +// MUST first run RequireAuth to populate user and RequireRealm to populate the +// realm. +func RequireVerified(ctx context.Context, authProvider auth.Provider, db *database.Database, h *render.Renderer, ttl time.Duration) mux.MiddlewareFunc { logger := logging.FromContext(ctx).Named("middleware.RequireVerified") return func(next http.Handler) http.Handler { @@ -46,41 +47,39 @@ func RequireVerified(ctx context.Context, client *auth.Client, db *database.Data controller.MissingSession(w, r, h) return } - flash := controller.Flash(session) currentUser := controller.UserFromContext(ctx) if currentUser == nil { - logger.Debugw("no user found when checking email verification") + authProvider.ClearSession(ctx, session) + flash.Error("Log in first to verify email.") - controller.ClearSessionFirebaseCookie(session) controller.MissingUser(w, r, h) return } - firebaseUser := controller.FirebaseUserFromContext(ctx) realm := controller.RealmFromContext(ctx) - if needsEmailVerification(session, realm, firebaseUser) { - logger.Debugw("user email not verified") - http.Redirect(w, r, "/login/manage-account?mode=verifyEmail", http.StatusSeeOther) + if realm == nil { + controller.MissingRealm(w, r, h) return } - next.ServeHTTP(w, r) - }) - } -} + // Only try to verify email if the realm requires it. + if realm.EmailVerifiedMode == database.MFARequired || + (realm.EmailVerifiedMode == database.MFAOptionalPrompt && !controller.EmailVerificationPromptedFromSession(session)) { + verified, err := authProvider.EmailVerified(ctx, session) + if err != nil { + controller.InternalError(w, r, h, err) + return + } -func needsEmailVerification(session *sessions.Session, realm *database.Realm, firebaseUser *auth.UserRecord) bool { - if realm == nil || realm.EmailVerifiedMode == database.MFARequired { - return !firebaseUser.EmailVerified - } + if !verified { + http.Redirect(w, r, "/login/manage-account?mode=verifyEmail", http.StatusSeeOther) + return + } + } - if realm.EmailVerifiedMode == database.MFAOptionalPrompt && - !controller.EmailVerificationPromptedFromSession(session) && - !firebaseUser.EmailVerified { - return true + next.ServeHTTP(w, r) + }) } - - return false } diff --git a/pkg/controller/middleware/mfa.go b/pkg/controller/middleware/mfa.go index 4714a99de..386ae7e0e 100644 --- a/pkg/controller/middleware/mfa.go +++ b/pkg/controller/middleware/mfa.go @@ -18,18 +18,18 @@ import ( "context" "net/http" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" "github.com/google/exposure-notifications-verification-server/pkg/render" "github.com/gorilla/mux" - "github.com/gorilla/sessions" ) // RequireMFA checks the realm's MFA requirements and enforces them. // Use requireRealm before requireMFA to ensure the currently selected realm is on context. // If no realm is selected, this assumes MFA is required. -func RequireMFA(ctx context.Context, h *render.Renderer) mux.MiddlewareFunc { +func RequireMFA(ctx context.Context, authProvider auth.Provider, h *render.Renderer) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -46,25 +46,27 @@ func RequireMFA(ctx context.Context, h *render.Renderer) mux.MiddlewareFunc { return } - realm := controller.RealmFromContext(ctx) - if NeedsMFARedirect(session, currentUser, realm) { - controller.RedirectToMFA(w, r, h) + mfaEnabled, err := authProvider.MFAEnabled(ctx, session) + if err != nil { + controller.InternalError(w, r, h, err) return } + if !mfaEnabled { + realm := controller.RealmFromContext(ctx) + if realm == nil { + controller.MissingRealm(w, r, h) + return + } + + mode := realm.EffectiveMFAMode(currentUser) + if mode == database.MFARequired || (mode == database.MFAOptionalPrompt && !controller.MFAPromptedFromSession(session)) { + controller.RedirectToMFA(w, r, h) + return + } + } + next.ServeHTTP(w, r) }) } } - -func NeedsMFARedirect(session *sessions.Session, user *database.User, realm *database.Realm) bool { - if controller.FactorCountFromSession(session) > 0 { - return false - } - if mode := realm.EffectiveMFAMode(user); mode == database.MFARequired || - mode == database.MFAOptionalPrompt && !controller.MFAPromptedFromSession(session) { - return true - } - - return false -} diff --git a/pkg/controller/session.go b/pkg/controller/session.go index 8ecd3ca08..df8624f3a 100644 --- a/pkg/controller/session.go +++ b/pkg/controller/session.go @@ -28,45 +28,13 @@ type sessionKey string const ( emailVerificationPrompted = sessionKey("emailVerificationPrompted") - factorCount = sessionKey("factorCount") mfaPrompted = sessionKey("mfaPrompted") - sessionKeyFirebaseCookie = sessionKey("firebaseCookie") sessionKeyLastActivity = sessionKey("lastActivity") sessionKeyRealmID = sessionKey("realmID") sessionKeyWelcomeMessageDisplayed = sessionKey("welcomeMessageDisplayed") passwordExpireWarned = sessionKey("passwordExpireWarned") ) -// StoreSessionFirebaseCookie stores the firebase cookie in the session. If the -// provided session or cookie is nil/empty, it does nothing. -func StoreSessionFirebaseCookie(session *sessions.Session, firebaseCookie string) { - if session == nil || firebaseCookie == "" { - return - } - session.Values[sessionKeyFirebaseCookie] = firebaseCookie -} - -// ClearSessionFirebaseCookie clears the firebase cookie from the session. -func ClearSessionFirebaseCookie(session *sessions.Session) { - sessionClear(session, sessionKeyFirebaseCookie) -} - -// FirebaseCookieFromSession extracts the firebase cookie from the session. -func FirebaseCookieFromSession(session *sessions.Session) string { - v := sessionGet(session, sessionKeyFirebaseCookie) - if v == nil { - return "" - } - - t, ok := v.(string) - if !ok { - delete(session.Values, sessionKeyFirebaseCookie) - return "" - } - - return t -} - // StoreSessionRealm stores the realm's ID in the session. func StoreSessionRealm(session *sessions.Session, realm *database.Realm) { if session == nil || realm == nil { @@ -97,35 +65,6 @@ func RealmIDFromSession(session *sessions.Session) uint { return t } -// StoreSessionFactorCount stores count of MFA factors to session. -func StoreSessionFactorCount(session *sessions.Session, count uint) { - if session == nil { - return - } - session.Values[factorCount] = count -} - -// ClearSessionFactorCount clears the MFA factor count from the session. -func ClearSessionFactorCount(session *sessions.Session) { - sessionClear(session, factorCount) -} - -// FactorCountFromSession extracts the number of MFA factors from the session. -func FactorCountFromSession(session *sessions.Session) uint { - v := sessionGet(session, factorCount) - if v == nil { - return 0 - } - - f, ok := v.(uint) - if !ok { - delete(session.Values, factorCount) - return 0 - } - - return f -} - // StoreSessionMFAPrompted stores if the user was prompted for MFA. func StoreSessionMFAPrompted(session *sessions.Session, prompted bool) { if session == nil { diff --git a/pkg/controller/user/controller.go b/pkg/controller/user/controller.go index 9e345d230..6902427e6 100644 --- a/pkg/controller/user/controller.go +++ b/pkg/controller/user/controller.go @@ -18,8 +18,7 @@ package user import ( "context" - "firebase.google.com/go/auth" - "github.com/google/exposure-notifications-verification-server/internal/firebase" + "github.com/google/exposure-notifications-verification-server/internal/auth" "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/database" @@ -32,20 +31,18 @@ import ( // Controller manages users type Controller struct { - cacher cache.Cacher - firebaseInternal *firebase.Client - client *auth.Client - config *config.ServerConfig - db *database.Database - h *render.Renderer - logger *zap.SugaredLogger + cacher cache.Cacher + authProvider auth.Provider + config *config.ServerConfig + db *database.Database + h *render.Renderer + logger *zap.SugaredLogger } // New creates a new controller for managing users. func New( ctx context.Context, - firebaseInternal *firebase.Client, - client *auth.Client, + authProvider auth.Provider, cacher cache.Cacher, config *config.ServerConfig, db *database.Database, @@ -53,12 +50,11 @@ func New( logger := logging.FromContext(ctx) return &Controller{ - cacher: cacher, - firebaseInternal: firebaseInternal, - client: client, - config: config, - db: db, - h: h, - logger: logger, + cacher: cacher, + authProvider: authProvider, + config: config, + db: db, + h: h, + logger: logger, } } diff --git a/pkg/controller/user/create.go b/pkg/controller/user/create.go index a3d17763b..82b96242e 100644 --- a/pkg/controller/user/create.go +++ b/pkg/controller/user/create.go @@ -80,12 +80,6 @@ func (c *Controller) HandleCreate() http.Handler { user.Name = form.Name } - // Create firebase user first, if this fails we don't want a db.User entry - if _, err := c.ensureFirebaseUserExists(ctx, user); err != nil { - c.renderNew(ctx, w) - return - } - // Build the user struct user.Realms = append(user.Realms, realm) if form.Admin { @@ -98,6 +92,18 @@ func (c *Controller) HandleCreate() http.Handler { return } + inviteComposer, err := controller.SendInviteEmailFunc(ctx, c.db, c.h, user.Email) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + + if _, err := c.authProvider.CreateUser(ctx, user.Name, user.Email, "", inviteComposer); err != nil { + flash.Alert("Failed to create user: %v", err) + c.renderNew(ctx, w) + return + } + flash.Alert("Successfully created user %v.", user.Name) stats, err := c.getStats(ctx, user, realm) @@ -106,7 +112,7 @@ func (c *Controller) HandleCreate() http.Handler { return } - c.renderShow(ctx, w, user, stats, false /*resetPassword*/) + c.renderShow(ctx, w, user, stats) }) } diff --git a/pkg/controller/user/importbatch.go b/pkg/controller/user/importbatch.go index edcbe7388..de7d187e6 100644 --- a/pkg/controller/user/importbatch.go +++ b/pkg/controller/user/importbatch.go @@ -15,7 +15,6 @@ package user import ( - "errors" "fmt" "net/http" @@ -69,24 +68,32 @@ func (c *Controller) HandleImportBatch() http.Handler { } user.Realms = append(user.Realms, realm) - if created, err := user.CreateFirebaseUser(ctx, c.client); err != nil { - logger.Errorw("Error creating firebase user", "error", err) + // Save the user in the database. + if err := c.db.SaveUser(user, currentUser); err != nil { + logger.Errorw("Error saving user", "error", err) batchErr = multierror.Append(batchErr, err) continue - } else if created { - newUsers = append(newUsers, &batchUser) + } - if err := c.sendInvitation(ctx, user.Email); err != nil { - batchErr = multierror.Append(batchErr, errors.New("send invitation failed")) - continue - } + // Create the invitation email composer. + inviteComposer, err := controller.SendInviteEmailFunc(ctx, c.db, c.h, user.Email) + if err != nil { + controller.InternalError(w, r, c.h, err) + return } - if err := c.db.SaveUser(user, currentUser); err != nil { - logger.Errorw("Error saving user", "error", err) + // Create the user in the auth provider. This could be a noop depending on + // the auth provider. + created, err := c.authProvider.CreateUser(ctx, user.Name, user.Email, "", inviteComposer) + if err != nil { + logger.Errorw("failed to import user", "user", user.Email, "error", err) batchErr = multierror.Append(batchErr, err) continue } + + if created { + newUsers = append(newUsers, &batchUser) + } } response := &api.UserBatchResponse{ diff --git a/pkg/controller/user/reset_password.go b/pkg/controller/user/reset_password.go index 318319efd..219dfbebc 100644 --- a/pkg/controller/user/reset_password.go +++ b/pkg/controller/user/reset_password.go @@ -15,44 +15,58 @@ package user import ( - "context" "net/http" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" - "go.opencensus.io/stats" + "github.com/gorilla/mux" ) func (c *Controller) HandleResetPassword() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c.Show(w, r, true /*resetPassword*/) - }) -} + ctx := r.Context() + vars := mux.Vars(r) -func (c *Controller) resetPasswordUserAssertion(ctx context.Context, user *database.User) error { - created, err := c.ensureFirebaseUserExists(ctx, user) - if created { - stats.Record(ctx, controller.MFirebaseRecreates.M(1)) - } - return err -} + session := controller.SessionFromContext(ctx) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + flash := controller.Flash(session) + + realm := controller.RealmFromContext(ctx) + if realm == nil { + controller.MissingRealm(w, r, c.h) + return + } + + // Pull the user from the id. + user, err := realm.FindUser(c.db, vars["id"]) + if err != nil { + if database.IsNotFound(err) { + controller.Unauthorized(w, r, c.h) + return + } -func (c *Controller) ensureFirebaseUserExists(ctx context.Context, user *database.User) (bool, error) { - session := controller.SessionFromContext(ctx) - flash := controller.Flash(session) - - // Ensure the firebase user is created - created, err := user.CreateFirebaseUser(ctx, c.client) - if err != nil { - flash.Alert("Failed to create user auth: %v", err) - return created, err - } - - if created { - if err := c.sendInvitation(ctx, user.Email); err != nil { - flash.Error("Could not send new user invitation.") - return true, err + controller.InternalError(w, r, c.h, err) + return } - } - return created, nil + + // Build the emailer. + resetComposer, err := controller.SendPasswordResetEmailFunc(ctx, c.db, c.h, user.Email) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + + // Reset the password. + if err := c.authProvider.SendResetPasswordEmail(ctx, user.Email, resetComposer); err != nil { + flash.Error("Failed to reset password: %v", err) + controller.Back(w, r, c.h) + return + } + + flash.Alert("Successfully sent password reset to %v", user.Email) + controller.Back(w, r, c.h) + }) } diff --git a/pkg/controller/user/send_invite.go b/pkg/controller/user/send_invite.go deleted file mode 100644 index a3773abd2..000000000 --- a/pkg/controller/user/send_invite.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2020 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package user - -import ( - "context" - "fmt" - - "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/email" -) - -func (c *Controller) sendInvitation(ctx context.Context, toEmail string) error { - compose := func(emailer email.Provider, realm *database.Realm, toEmail string) ([]byte, error) { - return controller.ComposeInviteEmail(ctx, c.h, c.client, toEmail, emailer.From(), realm.Name) - } - sent, err := controller.SendRealmEmail(ctx, c.db, compose, toEmail) - if err != nil { - c.logger.Warnw("failed sending invitation", "error", err) - return fmt.Errorf("failed sending invitation: %w", err) - } - if !sent { - // fallback to Firebase if not SMTP found - if err := c.firebaseInternal.SendNewUserInvitation(ctx, toEmail); err != nil { - c.logger.Warnw("failed sending invitation", "error", err) - return fmt.Errorf("failed sending invitation: %w", err) - } - } - - return nil -} diff --git a/pkg/controller/user/show.go b/pkg/controller/user/show.go index 138543eab..11d68b95a 100644 --- a/pkg/controller/user/show.go +++ b/pkg/controller/user/show.go @@ -60,17 +60,13 @@ func (c *Controller) Show(w http.ResponseWriter, r *http.Request, resetPassword return } - if resetPassword { - c.resetPasswordUserAssertion(ctx, user) - } - userStats, err := c.getStats(ctx, user, realm) if err != nil { controller.InternalError(w, r, c.h, err) return } - c.renderShow(ctx, w, user, userStats, resetPassword) + c.renderShow(ctx, w, user, userStats) } // Get and cache the stats for this user. @@ -90,12 +86,9 @@ func (c *Controller) getStats(ctx context.Context, user *database.User, realm *d return stats, nil } -func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, user *database.User, stats []*database.UserStats, resetPassword bool) { +func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, user *database.User, stats []*database.UserStats) { m := controller.TemplateMapFromContext(ctx) m["user"] = user m["stats"] = stats - if resetPassword { - m["firebase"] = c.config.Firebase - } c.h.RenderHTML(w, "users/show", m) } diff --git a/pkg/database/user.go b/pkg/database/user.go index 568bade11..2c515bb35 100644 --- a/pkg/database/user.go +++ b/pkg/database/user.go @@ -15,16 +15,13 @@ package database import ( - "context" "fmt" "sort" "strings" "time" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-server/pkg/timeutils" "github.com/jinzhu/gorm" - "github.com/sethvargo/go-password/password" ) const minDuration = -1 << 63 @@ -253,35 +250,6 @@ func (db *Database) TouchUserRevokeCheck(u *User) error { Error } -// CreateFirebaseUser creates the associated Firebase user for this database -// user. It does nothing if the firebase user already exists. If the firebase -// user does not exist, it generates a random password. The returned boolean -// indicates if the user was created. -func (u *User) CreateFirebaseUser(ctx context.Context, firebaseAuth *auth.Client) (bool, error) { - if _, err := firebaseAuth.GetUserByEmail(ctx, u.Email); err != nil { - if auth.IsInvalidEmail(err) { - return false, fmt.Errorf("invalid email: %q", u.Email) - } - if !auth.IsUserNotFound(err) { - return false, fmt.Errorf("failed lookup firebase user: %w", err) - } - - pwd, err := password.Generate(24, 8, 8, false, true) - if err != nil { - return false, fmt.Errorf("failed to generate password: %w", err) - } - - firebaseUser := &auth.UserToCreate{} - firebaseUser.Email(u.Email).Password(pwd).DisplayName(u.Name) - if _, err := firebaseAuth.CreateUser(ctx, firebaseUser); err != nil { - return false, fmt.Errorf("failed to create firebase user: %w", err) - } - return true, nil - } - - return false, nil -} - // PasswordChanged updates the last password change timestamp of the user. func (db *Database) PasswordChanged(email string, t time.Time) error { q := db.db.