1
0
mirror of https://github.com/volatiletech/authboss.git synced 2025-02-01 13:17:43 +02:00
authboss/recover/recover.go
Aaron L be041cbae6 remember: Context+Request separation ripple
- Re-add the age-old "Values" from the Context. This was originally
  there for exactly the documented purpose. However the Context holding
  the request form values negated it's use. It's back because of this
  new separation.
- Make the auth success path set the authboss.CookieRemember value in
  the context before calling it's callback.
2015-08-02 14:02:14 -07:00

313 lines
8.6 KiB
Go

// Package recover implements password reset via e-mail.
package recover
import (
"crypto/md5"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"time"
"golang.org/x/crypto/bcrypt"
"gopkg.in/authboss.v0"
"gopkg.in/authboss.v0/internal/response"
)
// Storage constants
const (
StoreRecoverToken = "recover_token"
StoreRecoverTokenExpiry = "recover_token_expiry"
)
const (
formValueToken = "token"
)
const (
methodGET = "GET"
methodPOST = "POST"
tplLogin = "login.html.tpl"
tplRecover = "recover.html.tpl"
tplRecoverComplete = "recover_complete.html.tpl"
tplInitHTMLEmail = "recover_email.html.tpl"
tplInitTextEmail = "recover_email.txt.tpl"
recoverInitiateSuccessFlash = "An email has been sent with further instructions on how to reset your password"
recoverTokenExpiredFlash = "Account recovery request has expired. Please try again."
recoverFailedErrorFlash = "Account recovery has failed. Please contact tech support."
)
var errRecoveryTokenExpired = errors.New("recovery token expired")
// RecoverStorer must be implemented in order to satisfy the recover module's
// storage requirements.
type RecoverStorer interface {
authboss.Storer
// RecoverUser looks a user up by a recover token. See recover module for
// attribute names. If the key is not found in the data store,
// simply return nil, ErrUserNotFound.
RecoverUser(recoverToken string) (interface{}, error)
}
func init() {
m := &Recover{}
authboss.RegisterModule("recover", m)
}
// Recover module
type Recover struct {
*authboss.Authboss
templates response.Templates
emailHTMLTemplates response.Templates
emailTextTemplates response.Templates
}
// Initialize module
func (r *Recover) Initialize(ab *authboss.Authboss) (err error) {
r.Authboss = ab
if r.Storer == nil {
return errors.New("recover: Need a RecoverStorer")
}
if _, ok := r.Storer.(RecoverStorer); !ok {
return errors.New("recover: RecoverStorer required for recover functionality")
}
if len(r.XSRFName) == 0 {
return errors.New("auth: XSRFName must be set")
}
if r.XSRFMaker == nil {
return errors.New("auth: XSRFMaker must be defined")
}
r.templates, err = response.LoadTemplates(r.Authboss, r.Layout, r.ViewsPath, tplRecover, tplRecoverComplete)
if err != nil {
return err
}
r.emailHTMLTemplates, err = response.LoadTemplates(r.Authboss, r.LayoutHTMLEmail, r.ViewsPath, tplInitHTMLEmail)
if err != nil {
return err
}
r.emailTextTemplates, err = response.LoadTemplates(r.Authboss, r.LayoutTextEmail, r.ViewsPath, tplInitTextEmail)
if err != nil {
return err
}
return nil
}
// Routes for module
func (r *Recover) Routes() authboss.RouteTable {
return authboss.RouteTable{
"/recover": r.startHandlerFunc,
"/recover/complete": r.completeHandlerFunc,
}
}
// Storage requirements
func (r *Recover) Storage() authboss.StorageOptions {
return authboss.StorageOptions{
r.PrimaryID: authboss.String,
authboss.StoreEmail: authboss.String,
authboss.StorePassword: authboss.String,
StoreRecoverToken: authboss.String,
StoreRecoverTokenExpiry: authboss.String,
}
}
func (rec *Recover) startHandlerFunc(ctx *authboss.Context, w http.ResponseWriter, r *http.Request) error {
switch r.Method {
case methodGET:
data := authboss.NewHTMLData(
"primaryID", rec.PrimaryID,
"primaryIDValue", "",
"confirmPrimaryIDValue", "",
)
return rec.templates.Render(ctx, w, r, tplRecover, data)
case methodPOST:
primaryID := r.FormValue(rec.PrimaryID)
confirmPrimaryID := r.FormValue(fmt.Sprintf("confirm_%s", rec.PrimaryID))
errData := authboss.NewHTMLData(
"primaryID", rec.PrimaryID,
"primaryIDValue", primaryID,
"confirmPrimaryIDValue", confirmPrimaryID,
)
policies := authboss.FilterValidators(rec.Policies, rec.PrimaryID)
if validationErrs := authboss.Validate(r, policies, rec.PrimaryID, authboss.ConfirmPrefix+rec.PrimaryID).Map(); len(validationErrs) > 0 {
errData.MergeKV("errs", validationErrs)
return rec.templates.Render(ctx, w, r, tplRecover, errData)
}
// redirect to login when user not found to prevent username sniffing
if err := ctx.LoadUser(primaryID); err == authboss.ErrUserNotFound {
return authboss.ErrAndRedirect{err, rec.RecoverOKPath, recoverInitiateSuccessFlash, ""}
} else if err != nil {
return err
}
email, err := ctx.User.StringErr(authboss.StoreEmail)
if err != nil {
return err
}
encodedToken, encodedChecksum, err := newToken()
if err != nil {
return err
}
ctx.User[StoreRecoverToken] = encodedChecksum
ctx.User[StoreRecoverTokenExpiry] = time.Now().Add(rec.RecoverTokenDuration)
if err := ctx.SaveUser(); err != nil {
return err
}
goRecoverEmail(rec, email, encodedToken)
ctx.SessionStorer.Put(authboss.FlashSuccessKey, recoverInitiateSuccessFlash)
response.Redirect(ctx, w, r, rec.RecoverOKPath, "", "", true)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
return nil
}
func newToken() (encodedToken, encodedChecksum string, err error) {
token := make([]byte, 32)
if _, err = rand.Read(token); err != nil {
return "", "", err
}
sum := md5.Sum(token)
return base64.URLEncoding.EncodeToString(token), base64.StdEncoding.EncodeToString(sum[:]), nil
}
var goRecoverEmail = func(r *Recover, to, encodedToken string) {
go r.sendRecoverEmail(to, encodedToken)
}
func (r *Recover) sendRecoverEmail(to, encodedToken string) {
p := path.Join(r.MountPath, "recover/complete")
query := url.Values{formValueToken: []string{encodedToken}}
url := fmt.Sprintf("%s%s?%s", r.RootURL, p, query.Encode())
email := authboss.Email{
To: []string{to},
From: r.EmailFrom,
Subject: r.EmailSubjectPrefix + "Password Reset",
}
if err := response.Email(r.Mailer, email, r.emailHTMLTemplates, tplInitHTMLEmail, r.emailTextTemplates, tplInitTextEmail, url); err != nil {
fmt.Fprintln(r.LogWriter, "recover: failed to send recover email:", err)
}
}
func (r *Recover) completeHandlerFunc(ctx *authboss.Context, w http.ResponseWriter, req *http.Request) (err error) {
switch req.Method {
case methodGET:
_, err = verifyToken(ctx, req)
if err == errRecoveryTokenExpired {
return authboss.ErrAndRedirect{err, "/recover", "", recoverTokenExpiredFlash}
} else if err != nil {
return authboss.ErrAndRedirect{err, "/", "", ""}
}
token := req.FormValue(formValueToken)
data := authboss.NewHTMLData(formValueToken, token)
return r.templates.Render(ctx, w, req, tplRecoverComplete, data)
case methodPOST:
token := req.FormValue(formValueToken)
if len(token) == 0 {
return authboss.ClientDataErr{formValueToken}
}
password := req.FormValue(authboss.StorePassword)
//confirmPassword, _ := ctx.FirstPostFormValue("confirmPassword")
policies := authboss.FilterValidators(r.Policies, authboss.StorePassword)
if validationErrs := authboss.Validate(req, policies, authboss.StorePassword, authboss.ConfirmPrefix+authboss.StorePassword).Map(); len(validationErrs) > 0 {
data := authboss.NewHTMLData(
formValueToken, token,
"errs", validationErrs,
)
return r.templates.Render(ctx, w, req, tplRecoverComplete, data)
}
if ctx.User, err = verifyToken(ctx, req); err != nil {
return err
}
encryptedPassword, err := bcrypt.GenerateFromPassword([]byte(password), r.BCryptCost)
if err != nil {
return err
}
ctx.User[authboss.StorePassword] = string(encryptedPassword)
ctx.User[StoreRecoverToken] = ""
var nullTime time.Time
ctx.User[StoreRecoverTokenExpiry] = nullTime
primaryID, err := ctx.User.StringErr(r.PrimaryID)
if err != nil {
return err
}
if err := ctx.SaveUser(); err != nil {
return err
}
if err := r.Callbacks.FireAfter(authboss.EventPasswordReset, ctx); err != nil {
return err
}
ctx.SessionStorer.Put(authboss.SessionKey, primaryID)
response.Redirect(ctx, w, req, r.AuthLoginOKPath, "", "", true)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
return nil
}
// verifyToken expects a base64.URLEncoded token.
func verifyToken(ctx *authboss.Context, r *http.Request) (attrs authboss.Attributes, err error) {
token := r.FormValue(formValueToken)
if len(token) == 0 {
return nil, authboss.ClientDataErr{token}
}
decoded, err := base64.URLEncoding.DecodeString(token)
if err != nil {
return nil, err
}
sum := md5.Sum(decoded)
storer := ctx.Storer.(RecoverStorer)
userInter, err := storer.RecoverUser(base64.StdEncoding.EncodeToString(sum[:]))
if err != nil {
return nil, err
}
attrs = authboss.Unbind(userInter)
expiry, ok := attrs.DateTime(StoreRecoverTokenExpiry)
if !ok || time.Now().After(expiry) {
return nil, errRecoveryTokenExpired
}
return attrs, nil
}