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

Server-side password reset #668

Merged
merged 17 commits into from
Sep 24, 2020
36 changes: 5 additions & 31 deletions cmd/server/assets/login/reset-password.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<head>
{{template "floatingform" .}}
{{template "head" .}}
{{template "firebase" .}}
</head>

<body class="tab-content">
Expand All @@ -26,9 +25,10 @@
</a>
</div>
<div class="card-body">
<form id="loginForm" class="floating-form" action="/" method="POST">
<form class="floating-form" action="/login/reset-password" method="POST">
{{ .csrfField }}
<div class="form-label-group mb-2">
<input type="email" id="email" name="email" class="form-control" placeholder="Email address" required
<input type="email" name="email" class="form-control" placeholder="Email address" required
autofocus />
<label for="email">Email address</label>
<small class="form-text text-muted">
Expand All @@ -49,34 +49,8 @@

<script type="text/javascript">
$(function() {
let $form = $('#loginForm');
sethvargo marked this conversation as resolved.
Show resolved Hide resolved
let $submit = $('#submit');
let $email = $('#email');

$form.on('submit', function(event) {
event.preventDefault();

if (window.confirm("Are you sure you want to reset your password?") !== true) {
return;
}

let email = $email.val();

// Disable the submit button so we only attempt once.
$submit.prop('disabled', true);

firebase.auth().sendPasswordResetEmail(email).then(function() {
flash.clear();
flash.alert('Password reset email sent.');
}).catch(function(error) {
flash.clear();
if (error.code = "auth/user-not-found") {
flash.alert('Password reset email sent.');
} else {
flash.error(error.message);
$submit.prop('disabled', false);
}
});
$('#submit').on('submit', function(event) {
return window.confirm("Are you sure you want to reset your password?");
});
});
</script>
Expand Down
9 changes: 5 additions & 4 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func realMain(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to configure firebase: %w", err)
}
fbInternal, err := iFB.New(ctx)
firebaseInternal, err := iFB.New(ctx)
if err != nil {
return fmt.Errorf("failed to configure internal firebase client: %w", err)
}
Expand Down Expand Up @@ -219,13 +219,14 @@ func realMain(ctx context.Context) error {
}

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

sub.Handle("/", loginController.HandleLogin()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleResetPassword()).Methods("GET")
sub.Handle("/login/reset-password", loginController.HandleShowResetPassword()).Methods("GET")
whaught marked this conversation as resolved.
Show resolved Hide resolved
sub.Handle("/login/reset-password", loginController.HandleSubmitResetPassword()).Methods("POST")
sub.Handle("/login/select-password", loginController.HandleShowSelectNewPassword()).Methods("GET")
sub.Handle("/login/select-password", loginController.HandleSubmitNewPassword()).Methods("POST")
sub.Handle("/session", loginController.HandleCreateSession()).Methods("POST")
Expand Down Expand Up @@ -336,7 +337,7 @@ func realMain(ctx context.Context) error {
userSub.Use(requireMFA)
userSub.Use(rateLimit)

userController := user.New(ctx, fbInternal, auth, cacher, config, db, h)
userController := user.New(ctx, firebaseInternal, auth, cacher, config, db, h)
userSub.Handle("", userController.HandleIndex()).Methods("GET")
userSub.Handle("", userController.HandleIndex()).Queries("offset", "{[0-9]*?}").Methods("GET")
userSub.Handle("", userController.HandleCreate()).Methods("POST")
Expand Down
44 changes: 44 additions & 0 deletions internal/firebase/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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

import "errors"

var (
ErrEmailNotFound = &ErrorDetails{Err: "EMAIL_NOT_FOUND"}
ErrInvalidOOBCode = &ErrorDetails{Err: "INVALID_OOB_CODE"}
ErrCredentialTooOld = &ErrorDetails{Err: "CREDENTIAL_TOO_OLD_LOGIN_AGAIN"}
ErrTokenExpired = &ErrorDetails{Err: "TOKEN_EXPIRED"}
ErrInvalidToken = &ErrorDetails{Err: "INVALID_ID_TOKEN"}
)

var _ error = (*ErrorDetails)(nil)

// ErrorDetails is the structure firebase gives back.
type ErrorDetails struct {
ErrorCode int `json:"code"`
Err string `json:"message"`
}

func (err *ErrorDetails) Error() string {
return err.Err
}

// ShouldReauthenticate returns true for errors that require a refreshed auth token.
func (err *ErrorDetails) ShouldReauthenticate() bool {
whaught marked this conversation as resolved.
Show resolved Hide resolved
return errors.Is(err, ErrCredentialTooOld) ||
errors.Is(err, ErrTokenExpired) ||
errors.Is(err, ErrInvalidToken)
}
7 changes: 7 additions & 0 deletions internal/firebase/password_reset_email.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ func (c *Client) SendPasswordResetEmail(ctx context.Context, email string) error
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))
}

Expand Down
31 changes: 20 additions & 11 deletions pkg/controller/login/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package login
import (
"context"

"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/database"
"github.com/google/exposure-notifications-verification-server/pkg/render"
Expand All @@ -29,22 +30,30 @@ import (
)

type Controller struct {
client *auth.Client
config *config.ServerConfig
db *database.Database
h *render.Renderer
logger *zap.SugaredLogger
firebaseInternal *firebase.Client
client *auth.Client
config *config.ServerConfig
db *database.Database
h *render.Renderer
logger *zap.SugaredLogger
}

// New creates a new login controller.
func New(ctx context.Context, client *auth.Client, config *config.ServerConfig, db *database.Database, h *render.Renderer) *Controller {
func New(
ctx context.Context,
firebaseInternal *firebase.Client,
client *auth.Client,
config *config.ServerConfig,
db *database.Database,
h *render.Renderer) *Controller {
logger := logging.FromContext(ctx).Named("login")

return &Controller{
client: client,
config: config,
db: db,
h: h,
logger: logger,
firebaseInternal: firebaseInternal,
client: client,
config: config,
db: db,
h: h,
logger: logger,
}
}
47 changes: 43 additions & 4 deletions pkg/controller/login/reset_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,56 @@
package login

import (
"context"
"errors"
"net/http"

"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"
)

func (c *Controller) HandleResetPassword() http.Handler {
func (c *Controller) HandleShowResetPassword() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
c.renderResetPassword(ctx, w, nil)
})
}

func (c *Controller) renderResetPassword(ctx context.Context, w http.ResponseWriter, f *flash.Flash) {
m := controller.TemplateMapFromContext(ctx)
m["flash"] = f
c.h.RenderHTML(w, "login/reset-password", m)
}

func (c *Controller) HandleSubmitResetPassword() http.Handler {
type FormData struct {
Email string `form:"email"`
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// There's no session yet, so make a one-time flash.
f := flash.New(nil)

var form FormData
if err := controller.BindForm(w, r, &form); err != nil {
f.Error("Password failed. %v", err)
c.renderResetPassword(ctx, w, f)
return
}

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) {
f.Error("Password reset failed.")
c.renderResetPassword(ctx, w, f)
return
}
sethvargo marked this conversation as resolved.
Show resolved Hide resolved
}

m := controller.TemplateMapFromContext(ctx)
m["firebase"] = c.config.Firebase
c.h.RenderHTML(w, "login/reset-password", m)
f.Alert("Password reset email sent.")
c.renderResetPassword(ctx, w, f)
})
}
2 changes: 1 addition & 1 deletion pkg/controller/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func (c *Controller) HandleCreate() http.Handler {
c.renderNew(ctx, w)
return
} else if created {
if err := c.fbInternal.SendPasswordResetEmail(ctx, user.Email); err != nil {
if err := c.firebaseInternal.SendPasswordResetEmail(ctx, user.Email); err != nil {
flash.Error("Failed sending new user invitation: %v", err)
c.renderNew(ctx, w)
return
Expand Down
2 changes: 1 addition & 1 deletion pkg/controller/user/importbatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (c *Controller) HandleImportBatch() http.Handler {
continue
} else if created {
newUsers = append(newUsers, &batchUser)
if err := c.fbInternal.SendPasswordResetEmail(ctx, user.Email); err != nil {
if err := c.firebaseInternal.SendPasswordResetEmail(ctx, user.Email); err != nil {
batchErr = multierror.Append(batchErr, err)
continue
}
Expand Down
32 changes: 16 additions & 16 deletions pkg/controller/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"context"

"firebase.google.com/go/auth"
iFB "github.com/google/exposure-notifications-verification-server/internal/firebase"
"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/database"
Expand All @@ -32,19 +32,19 @@ import (

// Controller manages users
type Controller struct {
cacher cache.Cacher
fbInternal *iFB.Client
client *auth.Client
config *config.ServerConfig
db *database.Database
h *render.Renderer
logger *zap.SugaredLogger
cacher cache.Cacher
firebaseInternal *firebase.Client
client *auth.Client
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,
fbInternal *iFB.Client,
firebaseInternal *firebase.Client,
client *auth.Client,
cacher cache.Cacher,
config *config.ServerConfig,
Expand All @@ -53,12 +53,12 @@ func New(
logger := logging.FromContext(ctx)

return &Controller{
cacher: cacher,
fbInternal: fbInternal,
client: client,
config: config,
db: db,
h: h,
logger: logger,
cacher: cacher,
firebaseInternal: firebaseInternal,
client: client,
config: config,
db: db,
h: h,
logger: logger,
}
}