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  }