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 }