github.com/volatiletech/authboss@v2.4.1+incompatible/otp/twofactor/sms2fa/sms.go (about) 1 // Package sms2fa implements two factor auth using 2 // sms-transmitted one time passwords. 3 package sms2fa 4 5 import ( 6 "context" 7 "crypto/rand" 8 "crypto/subtle" 9 "io" 10 "net/http" 11 "path" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/pkg/errors" 17 "github.com/volatiletech/authboss" 18 "github.com/volatiletech/authboss/otp/twofactor" 19 ) 20 21 // Session keys 22 const ( 23 SessionSMSNumber = "sms_number" 24 SessionSMSSecret = "sms_secret" 25 SessionSMSLast = "sms_last" 26 SessionSMSPendingPID = "sms_pending" 27 ) 28 29 // Form value constants 30 const ( 31 FormValueCode = "code" 32 FormValuePhoneNumber = "phone_number" 33 ) 34 35 // Pages 36 const ( 37 successSuffix = "_success" 38 39 PageSMSConfirm = "sms2fa_confirm" 40 PageSMSConfirmSuccess = "sms2fa_confirm_success" 41 PageSMSRemove = "sms2fa_remove" 42 PageSMSRemoveSuccess = "sms2fa_remove_success" 43 PageSMSSetup = "sms2fa_setup" 44 PageSMSValidate = "sms2fa_validate" 45 ) 46 47 // Data constants 48 const ( 49 DataSMSSecret = SessionSMSSecret 50 DataSMSPhoneNumber = "sms_phone_number" 51 ) 52 53 const ( 54 smsCodeLength = 6 55 smsRateLimitSeconds = 10 56 ) 57 58 var ( 59 errSMSRateLimit = errors.New("user sms send rate-limited") 60 errBadPhoneNumber = errors.New("bad phone number provided") 61 ) 62 63 // User for SMS 64 type User interface { 65 twofactor.User 66 67 GetSMSPhoneNumber() string 68 PutSMSPhoneNumber(string) 69 } 70 71 // SMSNumberProvider provides a phone number already attached 72 // to the user if it exists. This allows a user to be populated 73 // with a phone-number without the user needing to provide it. 74 type SMSNumberProvider interface { 75 GetSMSPhoneNumberSeed() string 76 } 77 78 // SMSSender sends SMS messages to a phone number 79 type SMSSender interface { 80 Send(ctx context.Context, number, text string) error 81 } 82 83 // SMS implements time based one time passwords 84 type SMS struct { 85 *authboss.Authboss 86 Sender SMSSender 87 } 88 89 // SMSValidator abstracts the send code/resend code/submit code workflow 90 type SMSValidator struct { 91 *SMS 92 Page string 93 } 94 95 // Setup the module 96 func (s *SMS) Setup() error { 97 if s.Sender == nil { 98 return errors.New("must have SMS.Sender set") 99 } 100 101 var unauthedResponse authboss.MWRespondOnFailure 102 if s.Config.Modules.ResponseOnUnauthed != 0 { 103 unauthedResponse = s.Config.Modules.ResponseOnUnauthed 104 } else if s.Config.Modules.RoutesRedirectOnUnauthed { 105 unauthedResponse = authboss.RespondRedirect 106 } 107 abmw := authboss.MountedMiddleware2(s.Authboss, true, authboss.RequireFullAuth, unauthedResponse) 108 109 var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler 110 middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler { 111 return abmw(s.Core.ErrorHandler.Wrap(handler)) 112 } 113 114 if s.Authboss.Config.Modules.TwoFactorEmailAuthRequired { 115 setupPath := path.Join(s.Authboss.Paths.Mount, "/2fa/sms/setup") 116 emailVerify, err := twofactor.SetupEmailVerify(s.Authboss, "sms", setupPath) 117 if err != nil { 118 return err 119 } 120 verified = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler { 121 return abmw(emailVerify.Wrap(s.Core.ErrorHandler.Wrap(handler))) 122 } 123 } else { 124 verified = middleware 125 } 126 127 s.Authboss.Core.Router.Get("/2fa/sms/setup", verified(s.GetSetup)) 128 s.Authboss.Core.Router.Post("/2fa/sms/setup", verified(s.PostSetup)) 129 130 confirm := &SMSValidator{SMS: s, Page: PageSMSConfirm} 131 s.Authboss.Core.Router.Get("/2fa/sms/confirm", verified(confirm.Get)) 132 s.Authboss.Core.Router.Post("/2fa/sms/confirm", verified(confirm.Post)) 133 134 remove := &SMSValidator{SMS: s, Page: PageSMSRemove} 135 s.Authboss.Core.Router.Get("/2fa/sms/remove", middleware(remove.Get)) 136 s.Authboss.Core.Router.Post("/2fa/sms/remove", middleware(remove.Post)) 137 138 validate := &SMSValidator{SMS: s, Page: PageSMSValidate} 139 s.Authboss.Core.Router.Get("/2fa/sms/validate", s.Core.ErrorHandler.Wrap(validate.Get)) 140 s.Authboss.Core.Router.Post("/2fa/sms/validate", s.Core.ErrorHandler.Wrap(validate.Post)) 141 142 s.Authboss.Events.Before(authboss.EventAuthHijack, s.HijackAuth) 143 144 return s.Authboss.Core.ViewRenderer.Load( 145 PageSMSConfirm, 146 PageSMSConfirmSuccess, 147 PageSMSRemove, 148 PageSMSRemoveSuccess, 149 PageSMSSetup, 150 PageSMSValidate, 151 ) 152 } 153 154 // HijackAuth stores the user's pid in a special temporary session variable 155 // and redirects them to the validation endpoint. 156 func (s *SMS) HijackAuth(w http.ResponseWriter, r *http.Request, handled bool) (bool, error) { 157 if handled { 158 return false, nil 159 } 160 161 user := r.Context().Value(authboss.CTXKeyUser).(User) 162 163 number := user.GetSMSPhoneNumber() 164 if len(number) == 0 { 165 return false, nil 166 } 167 168 authboss.PutSession(w, SessionSMSPendingPID, user.GetPID()) 169 err := s.SendCodeToUser(w, r, user.GetPID(), number) 170 if err != nil && err != errSMSRateLimit { 171 return false, err 172 } 173 174 var query string 175 if len(r.URL.RawQuery) != 0 { 176 query = "?" + r.URL.RawQuery 177 } 178 ro := authboss.RedirectOptions{ 179 Code: http.StatusTemporaryRedirect, 180 RedirectPath: s.Paths.Mount + "/2fa/sms/validate" + query, 181 } 182 return true, s.Authboss.Config.Core.Redirector.Redirect(w, r, ro) 183 } 184 185 // SendCodeToUser ensures that a code is sent to the user 186 func (s *SMS) SendCodeToUser(w http.ResponseWriter, r *http.Request, pid, number string) error { 187 code, err := generateRandomCode() 188 if err != nil { 189 return err 190 } 191 192 logger := s.RequestLogger(r) 193 194 if len(number) == 0 { 195 return errBadPhoneNumber 196 } 197 198 lastStr, ok := authboss.GetSession(r, SessionSMSLast) 199 suppress := false 200 if ok { 201 last, err := strconv.ParseInt(lastStr, 10, 64) 202 if err != nil { 203 return err 204 } 205 suppress = time.Now().UTC().Unix()-last < smsRateLimitSeconds 206 } 207 208 if suppress { 209 logger.Infof("rate-limited sms for %s to %s", pid, number) 210 return errSMSRateLimit 211 } 212 213 authboss.PutSession(w, SessionSMSLast, strconv.FormatInt(time.Now().UTC().Unix(), 10)) 214 authboss.PutSession(w, SessionSMSSecret, code) 215 216 logger.Infof("sending sms for %s to %s", pid, number) 217 if err := s.Sender.Send(r.Context(), number, code); err != nil { 218 logger.Infof("failed to send sms for %s to %s: %+v", pid, number, err) 219 return err 220 } 221 222 return nil 223 } 224 225 // GetSetup shows a screen that allows a user to opt in to setting up sms 2fa 226 // by asking for a phone number that's optionally already filled in. 227 func (s *SMS) GetSetup(w http.ResponseWriter, r *http.Request) error { 228 abUser, err := s.CurrentUser(r) 229 if err != nil { 230 return err 231 } 232 233 var data authboss.HTMLData 234 numberProvider, ok := abUser.(SMSNumberProvider) 235 if ok { 236 if val := numberProvider.GetSMSPhoneNumberSeed(); len(val) != 0 { 237 data = authboss.HTMLData{DataSMSPhoneNumber: val} 238 } 239 } 240 241 authboss.DelSession(w, SessionSMSSecret) 242 authboss.DelSession(w, SessionSMSNumber) 243 244 return s.Core.Responder.Respond(w, r, http.StatusOK, PageSMSSetup, data) 245 } 246 247 // PostSetup adds the phone number provided to the user's session and sends 248 // an SMS there. 249 func (s *SMS) PostSetup(w http.ResponseWriter, r *http.Request) error { 250 abUser, err := s.CurrentUser(r) 251 if err != nil { 252 return err 253 } 254 user := abUser.(User) 255 256 validator, err := s.Authboss.Config.Core.BodyReader.Read(PageSMSSetup, r) 257 if err != nil { 258 return err 259 } 260 261 smsVals := MustHaveSMSPhoneNumberValue(validator) 262 263 number := smsVals.GetPhoneNumber() 264 if len(number) == 0 { 265 data := authboss.HTMLData{ 266 authboss.DataValidation: map[string][]string{FormValuePhoneNumber: {"must provide a phone number"}}, 267 } 268 return s.Core.Responder.Respond(w, r, http.StatusOK, PageSMSSetup, data) 269 } 270 271 authboss.PutSession(w, SessionSMSNumber, number) 272 if err = s.SendCodeToUser(w, r, user.GetPID(), number); err != nil { 273 return err 274 } 275 276 ro := authboss.RedirectOptions{ 277 Code: http.StatusTemporaryRedirect, 278 RedirectPath: s.Paths.Mount + "/2fa/sms/confirm", 279 } 280 return s.Core.Redirector.Redirect(w, r, ro) 281 } 282 283 // Get shows an empty page typically, this allows us to prompt 284 // a second time for the action. 285 func (s *SMSValidator) Get(w http.ResponseWriter, r *http.Request) error { 286 return s.Core.Responder.Respond(w, r, http.StatusOK, s.Page, nil) 287 } 288 289 // Post receives a code in the body and validates it, if the code is 290 // missing then it sends the code to the user (rate-limited). 291 func (s *SMSValidator) Post(w http.ResponseWriter, r *http.Request) error { 292 // Get the user, they're either logged in and CurrentUser works, or they're 293 // in the middle of logging in and SMSPendingPID is set. 294 // Ensure we always look up CurrentUser first or session persistence 295 // attacks can be performed. 296 abUser, err := s.Authboss.CurrentUser(r) 297 if err == authboss.ErrUserNotFound { 298 pid, ok := authboss.GetSession(r, SessionSMSPendingPID) 299 if ok && len(pid) != 0 { 300 abUser, err = s.Authboss.Config.Storage.Server.Load(r.Context(), pid) 301 } 302 } 303 if err != nil { 304 return err 305 } 306 307 user := abUser.(User) 308 309 validator, err := s.Authboss.Config.Core.BodyReader.Read(s.Page, r) 310 if err != nil { 311 return err 312 } 313 smsCodeValues := MustHaveSMSValues(validator) 314 315 var inputCode, recoveryCode string 316 inputCode = smsCodeValues.GetCode() 317 318 // Only allow recovery codes on login/remove operations 319 if s.Page == PageSMSValidate || s.Page == PageSMSRemove { 320 recoveryCode = smsCodeValues.GetRecoveryCode() 321 } 322 323 if len(recoveryCode) == 0 && len(inputCode) == 0 { 324 return s.sendCode(w, r, user) 325 } 326 327 if len(recoveryCode) != 0 { 328 return s.validateCode(w, r, user, "", recoveryCode) 329 } 330 331 return s.validateCode(w, r, user, inputCode, "") 332 } 333 334 func (s *SMSValidator) sendCode(w http.ResponseWriter, r *http.Request, user User) error { 335 var phoneNumber string 336 337 // Get the phone number, when we're confirming the phone number is not 338 // yet stored in the user but inside the session. 339 switch s.Page { 340 case PageSMSConfirm: 341 var ok bool 342 phoneNumber, ok = authboss.GetSession(r, SessionSMSNumber) 343 if !ok { 344 return errors.New("request failed, no sms number present in session") 345 } 346 347 case PageSMSValidate, PageSMSRemove: 348 phoneNumber = user.GetSMSPhoneNumber() 349 } 350 351 if len(phoneNumber) == 0 { 352 return errors.Errorf("no phone number was available in PostSendCode for user %s", user.GetPID()) 353 } 354 355 var data authboss.HTMLData 356 err := s.SendCodeToUser(w, r, user.GetPID(), phoneNumber) 357 if err == errSMSRateLimit { 358 data = authboss.HTMLData{authboss.DataErr: "please wait a few moments before resending SMS code"} 359 } else if err != nil { 360 return err 361 } 362 363 return s.Core.Responder.Respond(w, r, http.StatusOK, s.Page, data) 364 } 365 366 func (s *SMSValidator) validateCode(w http.ResponseWriter, r *http.Request, user User, inputCode, recoveryCode string) error { 367 logger := s.RequestLogger(r) 368 369 var verified bool 370 if len(recoveryCode) != 0 { 371 var ok bool 372 recoveryCodes := twofactor.DecodeRecoveryCodes(user.GetRecoveryCodes()) 373 recoveryCodes, ok = twofactor.UseRecoveryCode(recoveryCodes, recoveryCode) 374 375 verified = ok 376 377 if verified { 378 logger.Infof("user %s used recovery code instead of sms2fa", user.GetPID()) 379 user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(recoveryCodes)) 380 if err := s.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil { 381 return err 382 } 383 } 384 } else { 385 code, ok := authboss.GetSession(r, SessionSMSSecret) 386 if !ok || len(code) == 0 { 387 return errors.Errorf("no code in session for user %s", user.GetPID()) 388 } 389 390 verified = 1 == subtle.ConstantTimeCompare([]byte(inputCode), []byte(code)) 391 } 392 393 if !verified { 394 r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user)) 395 handled, err := s.Authboss.Events.FireAfter(authboss.EventAuthFail, w, r) 396 if err != nil { 397 return err 398 } else if handled { 399 return nil 400 } 401 402 logger.Infof("user %s sms 2fa failure (wrong code)", user.GetPID()) 403 data := authboss.HTMLData{ 404 authboss.DataValidation: map[string][]string{FormValueCode: {"2fa code was invalid"}}, 405 } 406 return s.Authboss.Core.Responder.Respond(w, r, http.StatusOK, s.Page, data) 407 } 408 409 var data authboss.HTMLData 410 411 switch s.Page { 412 case PageSMSConfirm: 413 phoneNumber, ok := authboss.GetSession(r, SessionSMSNumber) 414 if !ok { 415 return errors.New("request failed, no sms number present in session") 416 } 417 418 codes, err := twofactor.GenerateRecoveryCodes() 419 if err != nil { 420 return err 421 } 422 423 crypted, err := twofactor.BCryptRecoveryCodes(codes) 424 if err != nil { 425 return err 426 } 427 428 // Save the user which activates 2fa (phone number should be stored from earlier) 429 user.PutSMSPhoneNumber(phoneNumber) 430 user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(crypted)) 431 if err = s.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil { 432 return err 433 } 434 435 authboss.DelSession(w, SessionSMSSecret) 436 authboss.DelSession(w, SessionSMSNumber) 437 438 logger.Infof("user %s enabled sms 2fa", user.GetPID()) 439 data = authboss.HTMLData{twofactor.DataRecoveryCodes: codes} 440 case PageSMSRemove: 441 user.PutSMSPhoneNumber("") 442 if err := s.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil { 443 return err 444 } 445 446 authboss.DelSession(w, authboss.Session2FA) 447 448 logger.Infof("user %s disabled sms 2fa", user.GetPID()) 449 case PageSMSValidate: 450 authboss.PutSession(w, authboss.SessionKey, user.GetPID()) 451 authboss.PutSession(w, authboss.Session2FA, "sms") 452 453 authboss.DelSession(w, authboss.SessionHalfAuthKey) 454 authboss.DelSession(w, SessionSMSPendingPID) 455 authboss.DelSession(w, SessionSMSSecret) 456 457 logger.Infof("user %s sms 2fa success", user.GetPID()) 458 459 r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user)) 460 handled, err := s.Authboss.Events.FireAfter(authboss.EventAuth, w, r) 461 if err != nil { 462 return err 463 } else if handled { 464 return nil 465 } 466 467 ro := authboss.RedirectOptions{ 468 Code: http.StatusTemporaryRedirect, 469 Success: "Successfully Authenticated", 470 RedirectPath: s.Authboss.Config.Paths.AuthLoginOK, 471 FollowRedirParam: true, 472 } 473 return s.Authboss.Core.Redirector.Redirect(w, r, ro) 474 default: 475 return errors.New("unknown action for sms validate") 476 } 477 478 return s.Authboss.Core.Responder.Respond(w, r, http.StatusOK, s.Page+successSuffix, data) 479 } 480 481 // generateRandomCode for sms auth 482 func generateRandomCode() (code string, err error) { 483 sb := new(strings.Builder) 484 485 random := make([]byte, smsCodeLength) 486 if _, err = io.ReadFull(rand.Reader, random); err != nil { 487 return "", err 488 } 489 490 for i := range random { 491 sb.WriteByte(random[i]%10 + 48) 492 } 493 494 return sb.String(), nil 495 }