Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Recreate firebase user on password reset #685

Closed
wants to merge 19 commits into from
Closed
36 changes: 6 additions & 30 deletions cmd/server/assets/users/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ <h1>{{$user.Name}}</h1>
{{$user.CanAdminRealm $currentRealm.ID}}
</div>

<button id="password-reset" class="btn btn-primary btn-block disabled">
Send password reset
</button>

<form class="floating-form" action="/users/{{$user.ID}}" method="POST">
{{ .csrfField }}
<input type="hidden" name="email" value="{{$user.Email}}"/>
<button type="password-reset" id="submit" class="btn btn-primary btn-block">Send password reset</button>
</form>
</div>
</div>

Expand Down Expand Up @@ -111,33 +114,6 @@ <h1>{{$user.Name}}</h1>
}
</script>
{{end}}

<script type="text/javascript">
$(function() {
$resetPassword = $('#password-reset');
$resetPassword.removeClass('disabled');

$resetPassword.on('click', function(event) {
event.preventDefault();
sendReset(function() {
flash.alert(`Password reset email sent.`);
});
});

function sendReset(successFn) {
$resetPassword.addClass('disabled');
let email = {{$user.Email}};
return firebase.auth().sendPasswordResetEmail(email)
.then(function() {
setTimeout($resetPassword.addClass('disabled'), 10000);
successFn();
}).catch(function(error) {
$resetPassword.removeClass('disabled');
flash.error(error.message);
});
}
});
</script>
</body>
</html>
{{end}}
111 changes: 55 additions & 56 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,62 +218,60 @@ func realMain(ctx context.Context) error {
sub.Handle("/health", controller.HandleHealthz(ctx, &cfg.Database, h)).Methods("GET")
}

ctx, loginController := login.New(ctx, firebaseInternal, auth, cfg, db, h)
{
loginController := login.New(ctx, firebaseInternal, auth, cfg, db, h)
{
sub := r.PathPrefix("").Subrouter()
sub.Use(rateLimit)

sub.Handle("/", loginController.HandleLogin()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleShowResetPassword()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleSubmitResetPassword()).Methods("POST")
// TODO(whaught): we can't customize separate links. Migrate to manage-account.
sub.Handle("/login/manage-account", loginController.HandleShowSelectNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("GET")
sub.Handle("/login/manage-account", loginController.HandleSubmitNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("POST")
sub.Handle("/login/select-password", loginController.HandleShowSelectNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("GET")
sub.Handle("/login/select-password", loginController.HandleSubmitNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("POST")
sub.Handle("/session", loginController.HandleCreateSession()).Methods("POST")
sub.Handle("/signout", loginController.HandleSignOut()).Methods("GET")

// Realm selection & account settings
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Handle("/login", loginController.HandleReauth()).Methods("GET")
sub.Handle("/login", loginController.HandleReauth()).Queries("redir", "").Methods("GET")
sub.Handle("/login/select-realm", loginController.HandleSelectRealm()).Methods("GET", "POST")
sub.Handle("/login/change-password", loginController.HandleShowChangePassword()).Methods("GET")
sub.Handle("/login/change-password", loginController.HandleSubmitChangePassword()).Methods("POST")
sub.Handle("/account", loginController.HandleAccountSettings()).Methods("GET")

// Verifying email requires the user is logged in
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Use(requireRealm)
sub.Use(processFirewall)
// TODO(whaught): we can't customize separate links. Migrate to manage-account.
sub.Handle("/login/manage-account", loginController.HandleVerifyEmail()).
Queries("mode", "verifyEmail").Methods("GET")
sub.Handle("/login/select-password", loginController.HandleVerifyEmail()).
Queries("mode", "verifyEmail").Methods("GET")

// SMS auth registration is realm-specific, so it needs to load the current realm.
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Use(requireRealm)
sub.Use(processFirewall)
sub.Use(requireVerified)
sub.Handle("/login/register-phone", loginController.HandleRegisterPhone()).Methods("GET")
}
sub := r.PathPrefix("").Subrouter()
sub.Use(rateLimit)

sub.Handle("/", loginController.HandleLogin()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleShowResetPassword()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleSubmitResetPassword()).Methods("POST")
// TODO(whaught): we can't customize separate links. Migrate to manage-account.
sub.Handle("/login/manage-account", loginController.HandleShowSelectNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("GET")
sub.Handle("/login/manage-account", loginController.HandleSubmitNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("POST")
sub.Handle("/login/select-password", loginController.HandleShowSelectNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("GET")
sub.Handle("/login/select-password", loginController.HandleSubmitNewPassword()).
Queries("oobCode", "", "mode", "{resetPassword|recoverEmail}").Methods("POST")
sub.Handle("/session", loginController.HandleCreateSession()).Methods("POST")
sub.Handle("/signout", loginController.HandleSignOut()).Methods("GET")

// Realm selection & account settings
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Handle("/login", loginController.HandleReauth()).Methods("GET")
sub.Handle("/login", loginController.HandleReauth()).Queries("redir", "").Methods("GET")
sub.Handle("/login/select-realm", loginController.HandleSelectRealm()).Methods("GET", "POST")
sub.Handle("/login/change-password", loginController.HandleShowChangePassword()).Methods("GET")
sub.Handle("/login/change-password", loginController.HandleSubmitChangePassword()).Methods("POST")
sub.Handle("/account", loginController.HandleAccountSettings()).Methods("GET")

// Verifying email requires the user is logged in
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Use(requireRealm)
sub.Use(processFirewall)
// TODO(whaught): we can't customize separate links. Migrate to manage-account.
sub.Handle("/login/manage-account", loginController.HandleVerifyEmail()).
Queries("mode", "verifyEmail").Methods("GET")
sub.Handle("/login/select-password", loginController.HandleVerifyEmail()).
Queries("mode", "verifyEmail").Methods("GET")

// SMS auth registration is realm-specific, so it needs to load the current realm.
sub = r.PathPrefix("").Subrouter()
sub.Use(requireAuth)
sub.Use(rateLimit)
sub.Use(loadCurrentRealm)
sub.Use(requireRealm)
sub.Use(processFirewall)
sub.Use(requireVerified)
sub.Handle("/login/register-phone", loginController.HandleRegisterPhone()).Methods("GET")
}

{
Expand Down Expand Up @@ -337,6 +335,7 @@ func realMain(ctx context.Context) error {
}

// users
ctx, userController := user.New(ctx, firebaseInternal, auth, cacher, cfg, db, h)
{
userSub := r.PathPrefix("/users").Subrouter()
userSub.Use(requireAuth)
Expand All @@ -348,7 +347,6 @@ func realMain(ctx context.Context) error {
userSub.Use(requireMFA)
userSub.Use(rateLimit)

userController := user.New(ctx, firebaseInternal, auth, cacher, cfg, db, h)
userSub.Handle("", userController.HandleIndex()).Methods("GET")
userSub.Handle("", userController.HandleIndex()).
Queries("offset", "{[0-9]*}", "email", "").Methods("GET")
Expand All @@ -358,6 +356,7 @@ func realMain(ctx context.Context) error {
userSub.Handle("/import", userController.HandleImportBatch()).Methods("POST")
userSub.Handle("/{id}/edit", userController.HandleUpdate()).Methods("GET")
userSub.Handle("/{id}", userController.HandleShow()).Methods("GET")
userSub.Handle("/{id}", userController.HandleResetPassword()).Methods("POST")
userSub.Handle("/{id}", userController.HandleUpdate()).Methods("PATCH")
userSub.Handle("/{id}", userController.HandleDelete()).Methods("DELETE")
}
Expand Down
12 changes: 9 additions & 3 deletions pkg/controller/login/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/google/exposure-notifications-verification-server/internal/firebase"
"github.com/google/exposure-notifications-verification-server/pkg/config"
"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"

Expand All @@ -34,6 +35,7 @@ type Controller struct {
client *auth.Client
config *config.ServerConfig
db *database.Database
metrics *controller.Metrics
h *render.Renderer
logger *zap.SugaredLogger
}
Expand All @@ -45,13 +47,17 @@ func New(
client *auth.Client,
config *config.ServerConfig,
db *database.Database,
h *render.Renderer) *Controller {
h *render.Renderer) (context.Context, *Controller) {
logger := logging.FromContext(ctx).Named("login")

return &Controller{
ctx, metrics, err := controller.MetricsFromContext(ctx)
if err != nil {
logger.Errorw("failed to register shared metrics", "error", err)
}
return ctx, &Controller{
firebaseInternal: firebaseInternal,
client: client,
config: config,
metrics: metrics,
db: db,
h: h,
logger: logger,
Expand Down
9 changes: 9 additions & 0 deletions pkg/controller/login/reset_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"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"

"go.opencensus.io/stats"
)

func (c *Controller) HandleShowResetPassword() http.Handler {
Expand Down Expand Up @@ -55,6 +57,13 @@ func (c *Controller) HandleSubmitResetPassword() http.Handler {
return
}

// Ensure that if we have a user, they have auth
if user, err := c.db.FindUserByEmail(form.Email); err == nil {
if created, _ := user.CreateFirebaseUser(ctx, c.client); created {
stats.Record(ctx, c.metrics.FirebaseRecreates.M(1))
}
}

if err := c.firebaseInternal.SendPasswordResetEmail(ctx, form.Email); err != nil {
// Treat not-found like success so we don't leak details.
if !errors.Is(err, firebase.ErrEmailNotFound) {
Expand Down
62 changes: 62 additions & 0 deletions pkg/controller/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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/pkg/observability"

"go.opencensus.io/stats"
"go.opencensus.io/stats/view"
"go.opencensus.io/tag"
)

const contextKeyMetrics = contextKey("controllerMetricsKey")

var MetricPrefix = observability.MetricRoot + "/server/shared"

type Metrics struct {
FirebaseRecreates *stats.Int64Measure
}

func MetricsFromContext(ctx context.Context) (context.Context, *Metrics, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't love the dependency through context - it now implies an ordering between controller creation.

I still think there are other solutions that don't require that ordering / dependency

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by ordering? This is lazily initialized as a singleton on context, if the controller can't find it on context, it gets created. Controllers can be organized in any order (the first one will create)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it requires that everyone who calls it shares a context though, otherwise it will be created twice.

that may be OK in opencensus - I'm not positive

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think OC actually requires you to use the ctx - all the data is stored on the ctx

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna defer to @yegle and @icco here on the best practice

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed all that and shoved it into the database per our conversation. I think sharing context makes sense but @mikehelmick prefers the tighter scoping in main.go

v := ctx.Value(contextKeyMetrics)
if v == nil {
return registerMetrics(ctx)
}

if m, ok := v.(*Metrics); ok {
return ctx, m, nil
}
return registerMetrics(ctx)
}

func registerMetrics(ctx context.Context) (context.Context, *Metrics, error) {
mFirebaseRecreates := stats.Int64(MetricPrefix+"/fb_recreate", "recreation of firebase users", stats.UnitDimensionless)
if err := view.Register(&view.View{
Name: MetricPrefix + "/fb_recreate_count",
Measure: mFirebaseRecreates,
Description: "The count of firebase account recreations",
TagKeys: []tag.Key{observability.RealmTagKey},
Aggregation: view.Count(),
}); err != nil {
return ctx, nil, fmt.Errorf("stat view registration failure: %w", err)
}

metrics := &Metrics{FirebaseRecreates: mFirebaseRecreates}
return context.WithValue(ctx, contextKeyMetrics, metrics), metrics, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/google/exposure-notifications-verification-server/internal/firebase"
"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"
"github.com/google/exposure-notifications-verification-server/pkg/database"
"github.com/google/exposure-notifications-verification-server/pkg/render"

Expand All @@ -37,6 +38,7 @@ type Controller struct {
client *auth.Client
config *config.ServerConfig
db *database.Database
metrics *controller.Metrics
h *render.Renderer
logger *zap.SugaredLogger
}
Expand All @@ -49,15 +51,20 @@ func New(
cacher cache.Cacher,
config *config.ServerConfig,
db *database.Database,
h *render.Renderer) *Controller {
h *render.Renderer) (context.Context, *Controller) {
logger := logging.FromContext(ctx)
ctx, metrics, err := controller.MetricsFromContext(ctx)
if err != nil {
logger.Errorw("failed to register shared metrics", "error", err)
}

return &Controller{
return ctx, &Controller{
cacher: cacher,
firebaseInternal: firebaseInternal,
client: client,
config: config,
db: db,
metrics: metrics,
h: h,
logger: logger,
}
Expand Down
9 changes: 1 addition & 8 deletions pkg/controller/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,9 @@ func (c *Controller) HandleCreate() http.Handler {
}

// Create firebase user first, if this fails we don't want a db.User entry
if created, err := user.CreateFirebaseUser(ctx, c.client); err != nil {
flash.Alert("Failed to create user: %v", err)
if _, err := c.createFirebaseUser(ctx, user); err != nil {
c.renderNew(ctx, w)
return
} else if created {
if err := c.firebaseInternal.SendPasswordResetEmail(ctx, user.Email); err != nil {
flash.Error("Failed sending new user invitation: %v", err)
c.renderNew(ctx, w)
return
}
}

// Build the user struct - keeping email and name if user already exists in another realm.
Expand Down
Loading