github.com/volatiletech/authboss@v2.4.1+incompatible/recover/recover.go (about)

     1  // Package recover implements password reset via e-mail.
     2  package recover
     3  
     4  import (
     5  	"context"
     6  	"crypto/rand"
     7  	"crypto/sha512"
     8  	"crypto/subtle"
     9  	"encoding/base64"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"net/http"
    14  	"net/url"
    15  	"path"
    16  	"time"
    17  
    18  	"github.com/volatiletech/authboss"
    19  	"golang.org/x/crypto/bcrypt"
    20  )
    21  
    22  // Constants for templates etc.
    23  const (
    24  	DataRecoverToken = "recover_token"
    25  	DataRecoverURL   = "recover_url"
    26  
    27  	FormValueToken = "token"
    28  
    29  	EmailRecoverHTML = "recover_html"
    30  	EmailRecoverTxt  = "recover_txt"
    31  
    32  	PageRecoverStart  = "recover_start"
    33  	PageRecoverMiddle = "recover_middle"
    34  	PageRecoverEnd    = "recover_end"
    35  
    36  	recoverInitiateSuccessFlash = "An email has been sent to you with further instructions on how to reset your password."
    37  
    38  	recoverTokenSize  = 64
    39  	recoverTokenSplit = recoverTokenSize / 2
    40  )
    41  
    42  func init() {
    43  	m := &Recover{}
    44  	authboss.RegisterModule("recover", m)
    45  }
    46  
    47  // Recover module
    48  type Recover struct {
    49  	*authboss.Authboss
    50  }
    51  
    52  // Init module
    53  func (r *Recover) Init(ab *authboss.Authboss) (err error) {
    54  	r.Authboss = ab
    55  
    56  	if err := r.Authboss.Config.Core.ViewRenderer.Load(PageRecoverStart, PageRecoverEnd); err != nil {
    57  		return err
    58  	}
    59  
    60  	if err := r.Authboss.Config.Core.MailRenderer.Load(EmailRecoverHTML, EmailRecoverTxt); err != nil {
    61  		return err
    62  	}
    63  
    64  	r.Authboss.Config.Core.Router.Get("/recover", r.Core.ErrorHandler.Wrap(r.StartGet))
    65  	r.Authboss.Config.Core.Router.Post("/recover", r.Core.ErrorHandler.Wrap(r.StartPost))
    66  	r.Authboss.Config.Core.Router.Get("/recover/end", r.Core.ErrorHandler.Wrap(r.EndGet))
    67  	r.Authboss.Config.Core.Router.Post("/recover/end", r.Core.ErrorHandler.Wrap(r.EndPost))
    68  
    69  	return nil
    70  }
    71  
    72  // StartGet starts the recover procedure by rendering a form for the user.
    73  func (r *Recover) StartGet(w http.ResponseWriter, req *http.Request) error {
    74  	return r.Authboss.Config.Core.Responder.Respond(w, req, http.StatusOK, PageRecoverStart, nil)
    75  }
    76  
    77  // StartPost starts the recover procedure using values provided from the user
    78  // usually from the StartGet's form.
    79  func (r *Recover) StartPost(w http.ResponseWriter, req *http.Request) error {
    80  	logger := r.RequestLogger(req)
    81  
    82  	validatable, err := r.Authboss.Core.BodyReader.Read(PageRecoverStart, req)
    83  	if err != nil {
    84  		return err
    85  	}
    86  
    87  	if errs := validatable.Validate(); errs != nil {
    88  		logger.Info("recover validation failed")
    89  		data := authboss.HTMLData{authboss.DataValidation: authboss.ErrorMap(errs)}
    90  		return r.Authboss.Core.Responder.Respond(w, req, http.StatusOK, PageRecoverStart, data)
    91  	}
    92  
    93  	recoverVals := authboss.MustHaveRecoverStartValues(validatable)
    94  
    95  	user, err := r.Authboss.Storage.Server.Load(req.Context(), recoverVals.GetPID())
    96  	if err == authboss.ErrUserNotFound {
    97  		logger.Infof("user %s was attempted to be recovered, user does not exist, faking successful response", recoverVals.GetPID())
    98  		ro := authboss.RedirectOptions{
    99  			Code:         http.StatusTemporaryRedirect,
   100  			RedirectPath: r.Authboss.Config.Paths.RecoverOK,
   101  			Success:      recoverInitiateSuccessFlash,
   102  		}
   103  		return r.Authboss.Core.Redirector.Redirect(w, req, ro)
   104  	}
   105  
   106  	ru := authboss.MustBeRecoverable(user)
   107  
   108  	selector, verifier, token, err := GenerateRecoverCreds()
   109  	if err != nil {
   110  		return err
   111  	}
   112  
   113  	ru.PutRecoverSelector(selector)
   114  	ru.PutRecoverVerifier(verifier)
   115  	ru.PutRecoverExpiry(time.Now().UTC().Add(r.Config.Modules.RecoverTokenDuration))
   116  
   117  	if err := r.Authboss.Storage.Server.Save(req.Context(), ru); err != nil {
   118  		return err
   119  	}
   120  
   121  	if r.Authboss.Modules.MailNoGoroutine {
   122  		r.SendRecoverEmail(req.Context(), ru.GetEmail(), token)
   123  	} else {
   124  		go r.SendRecoverEmail(req.Context(), ru.GetEmail(), token)
   125  	}
   126  
   127  	logger.Infof("user %s password recovery initiated", ru.GetPID())
   128  	ro := authboss.RedirectOptions{
   129  		Code:         http.StatusTemporaryRedirect,
   130  		RedirectPath: r.Authboss.Config.Paths.RecoverOK,
   131  		Success:      recoverInitiateSuccessFlash,
   132  	}
   133  	return r.Authboss.Core.Redirector.Redirect(w, req, ro)
   134  }
   135  
   136  // SendRecoverEmail to a specific e-mail address passing along the encodedToken
   137  // in an escaped URL to the templates.
   138  func (r *Recover) SendRecoverEmail(ctx context.Context, to, encodedToken string) {
   139  	logger := r.Authboss.Logger(ctx)
   140  
   141  	mailURL := r.mailURL(encodedToken)
   142  
   143  	email := authboss.Email{
   144  		To:       []string{to},
   145  		From:     r.Authboss.Config.Mail.From,
   146  		FromName: r.Authboss.Config.Mail.FromName,
   147  		Subject:  r.Authboss.Config.Mail.SubjectPrefix + "Password Reset",
   148  	}
   149  
   150  	ro := authboss.EmailResponseOptions{
   151  		HTMLTemplate: EmailRecoverHTML,
   152  		TextTemplate: EmailRecoverTxt,
   153  		Data: authboss.HTMLData{
   154  			DataRecoverURL: mailURL,
   155  		},
   156  	}
   157  
   158  	logger.Infof("sending recover e-mail to: %s", to)
   159  	if err := r.Authboss.Email(ctx, email, ro); err != nil {
   160  		logger.Errorf("failed to recover send e-mail to %s: %+v", to, err)
   161  	}
   162  }
   163  
   164  // EndGet shows a password recovery form, and it should have the token that
   165  // the user brought in the query parameters in it on submission.
   166  func (r *Recover) EndGet(w http.ResponseWriter, req *http.Request) error {
   167  	validatable, err := r.Authboss.Core.BodyReader.Read(PageRecoverMiddle, req)
   168  	if err != nil {
   169  		return err
   170  	}
   171  
   172  	values := authboss.MustHaveRecoverMiddleValues(validatable)
   173  	token := values.GetToken()
   174  
   175  	data := authboss.HTMLData{
   176  		DataRecoverToken: token,
   177  	}
   178  
   179  	return r.Authboss.Config.Core.Responder.Respond(w, req, http.StatusOK, PageRecoverEnd, data)
   180  }
   181  
   182  // EndPost retrieves the token
   183  func (r *Recover) EndPost(w http.ResponseWriter, req *http.Request) error {
   184  	logger := r.RequestLogger(req)
   185  
   186  	validatable, err := r.Authboss.Core.BodyReader.Read(PageRecoverEnd, req)
   187  	if err != nil {
   188  		return err
   189  	}
   190  
   191  	values := authboss.MustHaveRecoverEndValues(validatable)
   192  	password := values.GetPassword()
   193  	token := values.GetToken()
   194  
   195  	if errs := validatable.Validate(); errs != nil {
   196  		logger.Info("recovery validation failed")
   197  		data := authboss.HTMLData{
   198  			authboss.DataValidation: authboss.ErrorMap(errs),
   199  			DataRecoverToken:        token,
   200  		}
   201  		return r.Config.Core.Responder.Respond(w, req, http.StatusOK, PageRecoverEnd, data)
   202  	}
   203  
   204  	rawToken, err := base64.URLEncoding.DecodeString(token)
   205  	if err != nil {
   206  		logger.Infof("invalid recover token submitted, base64 decode failed: %+v", err)
   207  		return r.invalidToken(PageRecoverEnd, w, req)
   208  	}
   209  
   210  	if len(rawToken) != recoverTokenSize {
   211  		logger.Infof("invalid recover token submitted, size was wrong: %d", len(rawToken))
   212  		return r.invalidToken(PageRecoverEnd, w, req)
   213  	}
   214  
   215  	selectorBytes := sha512.Sum512(rawToken[:recoverTokenSplit])
   216  	verifierBytes := sha512.Sum512(rawToken[recoverTokenSplit:])
   217  	selector := base64.StdEncoding.EncodeToString(selectorBytes[:])
   218  
   219  	storer := authboss.EnsureCanRecover(r.Authboss.Config.Storage.Server)
   220  	user, err := storer.LoadByRecoverSelector(req.Context(), selector)
   221  	if err == authboss.ErrUserNotFound {
   222  		logger.Info("invalid recover token submitted, user not found")
   223  		return r.invalidToken(PageRecoverEnd, w, req)
   224  	} else if err != nil {
   225  		return err
   226  	}
   227  
   228  	if time.Now().UTC().After(user.GetRecoverExpiry()) {
   229  		logger.Infof("invalid recover token submitted, already expired: %+v", err)
   230  		return r.invalidToken(PageRecoverEnd, w, req)
   231  	}
   232  
   233  	dbVerifierBytes, err := base64.StdEncoding.DecodeString(user.GetRecoverVerifier())
   234  	if err != nil {
   235  		logger.Infof("invalid recover verifier stored in database: %s", user.GetRecoverVerifier())
   236  		return r.invalidToken(PageRecoverEnd, w, req)
   237  	}
   238  
   239  	if subtle.ConstantTimeEq(int32(len(verifierBytes)), int32(len(dbVerifierBytes))) != 1 ||
   240  		subtle.ConstantTimeCompare(verifierBytes[:], dbVerifierBytes) != 1 {
   241  		logger.Info("stored recover verifier does not match provided one")
   242  		return r.invalidToken(PageRecoverEnd, w, req)
   243  	}
   244  
   245  	pass, err := bcrypt.GenerateFromPassword([]byte(password), r.Authboss.Config.Modules.BCryptCost)
   246  	if err != nil {
   247  		return err
   248  	}
   249  
   250  	user.PutPassword(string(pass))
   251  	user.PutRecoverSelector("")             // Don't allow another recovery
   252  	user.PutRecoverVerifier("")             // Don't allow another recovery
   253  	user.PutRecoverExpiry(time.Now().UTC()) // Put current time for those DBs that can't handle 0 time
   254  
   255  	if err := storer.Save(req.Context(), user); err != nil {
   256  		return err
   257  	}
   258  
   259  	successMsg := "Successfully updated password"
   260  	if r.Authboss.Config.Modules.RecoverLoginAfterRecovery {
   261  		authboss.PutSession(w, authboss.SessionKey, user.GetPID())
   262  		successMsg += " and logged in"
   263  	}
   264  
   265  	ro := authboss.RedirectOptions{
   266  		Code:         http.StatusTemporaryRedirect,
   267  		RedirectPath: r.Authboss.Config.Paths.RecoverOK,
   268  		Success:      successMsg,
   269  	}
   270  	return r.Authboss.Config.Core.Redirector.Redirect(w, req, ro)
   271  }
   272  
   273  func (r *Recover) invalidToken(page string, w http.ResponseWriter, req *http.Request) error {
   274  	errorsAll := []error{errors.New("recovery token is invalid")}
   275  	data := authboss.HTMLData{authboss.DataValidation: authboss.ErrorMap(errorsAll)}
   276  	return r.Authboss.Core.Responder.Respond(w, req, http.StatusOK, PageRecoverEnd, data)
   277  }
   278  
   279  func (r *Recover) mailURL(token string) string {
   280  	query := url.Values{FormValueToken: []string{token}}
   281  
   282  	if len(r.Config.Mail.RootURL) != 0 {
   283  		return fmt.Sprintf("%s?%s", r.Config.Mail.RootURL+"/recover/end", query.Encode())
   284  	}
   285  
   286  	p := path.Join(r.Config.Paths.Mount, "recover/end")
   287  	return fmt.Sprintf("%s%s?%s", r.Config.Paths.RootURL, p, query.Encode())
   288  }
   289  
   290  // GenerateRecoverCreds generates pieces needed for user recovery
   291  // selector: hash of the first half of a 64 byte value
   292  // (to be stored in the database and used in SELECT query)
   293  // verifier: hash of the second half of a 64 byte value
   294  // (to be stored in database but never used in SELECT query)
   295  // token: the user-facing base64 encoded selector+verifier
   296  func GenerateRecoverCreds() (selector, verifier, token string, err error) {
   297  	rawToken := make([]byte, recoverTokenSize)
   298  	if _, err = io.ReadFull(rand.Reader, rawToken); err != nil {
   299  		return "", "", "", err
   300  	}
   301  	selectorBytes := sha512.Sum512(rawToken[:recoverTokenSplit])
   302  	verifierBytes := sha512.Sum512(rawToken[recoverTokenSplit:])
   303  
   304  	return base64.StdEncoding.EncodeToString(selectorBytes[:]),
   305  		base64.StdEncoding.EncodeToString(verifierBytes[:]),
   306  		base64.URLEncoding.EncodeToString(rawToken),
   307  		nil
   308  }