Implement 2FA (email) #3

merged
opened by willdot.net targeting main from willdot.net/cocoon: email-auth-factor

This implements 2FA (email only at the moment).

I have run this on my test account and tested various flows:

  • Turning 2FA on and off through the Bluesky app
  • Turning 2FA on and the updating my email address to a new one, and observing that 2FA is disabled
  • Logging in with 2FA enabled
  • Using a 3rd party app (tangled) to log into my account via OAuth and being able to provide the 2FA token (including providing the wrong password and token as well to ensure that when the correct password and token is provided, the flow continues as normal)
  • Logging into my PDS directly via the /account/login route with 2FA turned on
  • Create a new account and observing that the two_factor_type is preset to none

The UI for the the PDS /account/login screen is far from perfect and could use some improvements. For example once you're entered the handle and password, the page refreshes with the new token input, but the handle and password fields are now empty :(

+12 -2
models/models.go
··· 8 "github.com/bluesky-social/indigo/atproto/atcrypto" 9 ) 10 11 type Repo struct { 12 Did string `gorm:"primaryKey"` 13 CreatedAt time.Time ··· 29 Root []byte 30 Preferences []byte 31 Deactivated bool 32 } 33 34 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { ··· 121 } 122 123 type ReservedKey struct { 124 - KeyDid string `gorm:"primaryKey"` 125 - Did *string `gorm:"index"` 126 PrivateKey []byte 127 CreatedAt time.Time `gorm:"index"` 128 }
··· 8 "github.com/bluesky-social/indigo/atproto/atcrypto" 9 ) 10 11 + type TwoFactorType string 12 + 13 + var ( 14 + TwoFactorTypeNone = TwoFactorType("none") 15 + TwoFactorTypeEmail = TwoFactorType("email") 16 + ) 17 + 18 type Repo struct { 19 Did string `gorm:"primaryKey"` 20 CreatedAt time.Time ··· 36 Root []byte 37 Preferences []byte 38 Deactivated bool 39 + TwoFactorCode *string 40 + TwoFactorCodeExpiresAt *time.Time 41 + TwoFactorType TwoFactorType `gorm:"default:none"` 42 } 43 44 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { ··· 131 } 132 133 type ReservedKey struct { 134 + KeyDid string `gorm:"primaryKey"` 135 + Did *string `gorm:"index"` 136 PrivateKey []byte 137 CreatedAt time.Time `gorm:"index"` 138 }
+56 -9
server/handle_account_signin.go
··· 2 3 import ( 4 "errors" 5 "strings" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/gorilla/sessions" ··· 15 ) 16 17 type OauthSigninInput struct { 18 - Username string `form:"username"` 19 - Password string `form:"password"` 20 - QueryParams string `form:"query_params"` 21 } 22 23 func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { ··· 44 func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { 45 defer sess.Save(e.Request(), e.Response()) 46 return map[string]any{ 47 - "errors": sess.Flashes("error"), 48 - "successes": sess.Flashes("success"), 49 } 50 } 51 ··· 83 idtype = "email" 84 } 85 86 // TODO: we should make this a helper since we do it for the base create_session as well 87 var repo models.RepoActor 88 var err error ··· 101 sess.AddFlash("Something went wrong!", "error") 102 } 103 sess.Save(e.Request(), e.Response()) 104 - return e.Redirect(303, "/account/signin") 105 } 106 107 if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { ··· 111 sess.AddFlash("Something went wrong!", "error") 112 } 113 sess.Save(e.Request(), e.Response()) 114 - return e.Redirect(303, "/account/signin") 115 } 116 117 sess.Options = &sessions.Options{ ··· 127 return err 128 } 129 130 - if req.QueryParams != "" { 131 - return e.Redirect(303, "/oauth/authorize?"+req.QueryParams) 132 } else { 133 return e.Redirect(303, "/account") 134 }
··· 2 3 import ( 4 "errors" 5 + "fmt" 6 "strings" 7 + "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/gorilla/sessions" ··· 17 ) 18 19 type OauthSigninInput struct { 20 + Username string `form:"username"` 21 + Password string `form:"password"` 22 + AuthFactorToken string `form:"token"` 23 + QueryParams string `form:"query_params"` 24 } 25 26 func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { ··· 47 func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { 48 defer sess.Save(e.Request(), e.Response()) 49 return map[string]any{ 50 + "errors": sess.Flashes("error"), 51 + "successes": sess.Flashes("success"), 52 + "tokenrequired": sess.Flashes("tokenrequired"), 53 } 54 } 55 ··· 87 idtype = "email" 88 } 89 90 + queryParams := "" 91 + if req.QueryParams != "" { 92 + queryParams = fmt.Sprintf("?%s", req.QueryParams) 93 + } 94 + 95 // TODO: we should make this a helper since we do it for the base create_session as well 96 var repo models.RepoActor 97 var err error ··· 110 sess.AddFlash("Something went wrong!", "error") 111 } 112 sess.Save(e.Request(), e.Response()) 113 + return e.Redirect(303, "/account/signin"+queryParams) 114 } 115 116 if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { ··· 120 sess.AddFlash("Something went wrong!", "error") 121 } 122 sess.Save(e.Request(), e.Response()) 123 + return e.Redirect(303, "/account/signin"+queryParams) 124 + } 125 + 126 + // if repo requires 2FA token and one hasn't been provided, return error prompting for one 127 + if repo.TwoFactorType != models.TwoFactorTypeNone && req.AuthFactorToken == "" { 128 + err = s.createAndSendTwoFactorCode(ctx, repo) 129 + if err != nil { 130 + sess.AddFlash("Something went wrong!", "error") 131 + sess.Save(e.Request(), e.Response()) 132 + return e.Redirect(303, "/account/signin"+queryParams) 133 + } 134 + 135 + sess.AddFlash("requires 2FA token", "tokenrequired") 136 + sess.Save(e.Request(), e.Response()) 137 + return e.Redirect(303, "/account/signin"+queryParams) 138 + } 139 + 140 + // if 2FAis required, now check that the one provided is valid 141 + if repo.TwoFactorType != models.TwoFactorTypeNone { 142 + if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 143 + err = s.createAndSendTwoFactorCode(ctx, repo) 144 + if err != nil { 145 + sess.AddFlash("Something went wrong!", "error") 146 + sess.Save(e.Request(), e.Response()) 147 + return e.Redirect(303, "/account/signin"+queryParams) 148 + } 149 + 150 + sess.AddFlash("requires 2FA token", "tokenrequired") 151 + sess.Save(e.Request(), e.Response()) 152 + return e.Redirect(303, "/account/signin"+queryParams) 153 + } 154 + 155 + if *repo.TwoFactorCode != req.AuthFactorToken { 156 + return helpers.InvalidTokenError(e) 157 + } 158 + 159 + if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 160 + return helpers.ExpiredTokenError(e) 161 + } 162 } 163 164 sess.Options = &sessions.Options{ ··· 174 return err 175 } 176 177 + if queryParams != "" { 178 + return e.Redirect(303, "/oauth/authorize"+queryParams) 179 } else { 180 return e.Redirect(303, "/account") 181 }
+54 -1
server/handle_server_create_session.go
··· 1 package server 2 3 import ( 4 "errors" 5 "strings" 6 7 "github.com/Azure/go-autorest/autorest/to" 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 91 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 92 } 93 94 sess, err := s.createSession(ctx, &repo.Repo) 95 if err != nil { 96 logger.Error("error creating session", "error", err) ··· 104 Did: repo.Repo.Did, 105 Email: repo.Email, 106 EmailConfirmed: repo.EmailConfirmedAt != nil, 107 - EmailAuthFactor: false, 108 Active: repo.Active(), 109 Status: repo.Status(), 110 }) 111 }
··· 1 package server 2 3 import ( 4 + "context" 5 "errors" 6 + "fmt" 7 "strings" 8 + "time" 9 10 "github.com/Azure/go-autorest/autorest/to" 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 94 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 95 } 96 97 + // if repo requires 2FA token and one hasn't been provided, return error prompting for one 98 + if repo.TwoFactorType != models.TwoFactorTypeNone && (req.AuthFactorToken == nil || *req.AuthFactorToken == "") { 99 + err = s.createAndSendTwoFactorCode(ctx, repo) 100 + if err != nil { 101 + logger.Error("sending 2FA code", "error", err) 102 + return helpers.ServerError(e, nil) 103 + } 104 + 105 + return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired")) 106 + } 107 + 108 + // if 2FA is required, now check that the one provided is valid 109 + if repo.TwoFactorType != models.TwoFactorTypeNone { 110 + if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 111 + err = s.createAndSendTwoFactorCode(ctx, repo) 112 + if err != nil { 113 + logger.Error("sending 2FA code", "error", err) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired")) 118 + } 119 + 120 + if *repo.TwoFactorCode != *req.AuthFactorToken { 121 + return helpers.InvalidTokenError(e) 122 + } 123 + 124 + if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 125 + return helpers.ExpiredTokenError(e) 126 + } 127 + } 128 + 129 sess, err := s.createSession(ctx, &repo.Repo) 130 if err != nil { 131 logger.Error("error creating session", "error", err) ··· 139 Did: repo.Repo.Did, 140 Email: repo.Email, 141 EmailConfirmed: repo.EmailConfirmedAt != nil, 142 + EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 143 Active: repo.Active(), 144 Status: repo.Status(), 145 }) 146 } 147 + 148 + func (s *Server) createAndSendTwoFactorCode(ctx context.Context, repo models.RepoActor) error { 149 + // TODO: when implementing a new type of 2FA there should be some logic in here to send the 150 + // right type of code 151 + 152 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 153 + eat := time.Now().Add(10 * time.Minute).UTC() 154 + 155 + if err := s.db.Exec(ctx, "UPDATE repos SET two_factor_code = ?, two_factor_code_expires_at = ? WHERE did = ?", nil, code, eat, repo.Repo.Did).Error; err != nil { 156 + return fmt.Errorf("updating repo: %w", err) 157 + } 158 + 159 + if err := s.sendTwoFactorCode(repo.Email, repo.Handle, code); err != nil { 160 + return fmt.Errorf("sending email: %w", err) 161 + } 162 + 163 + return nil 164 + }
+1 -1
server/handle_server_get_session.go
··· 23 Did: repo.Repo.Did, 24 Email: repo.Email, 25 EmailConfirmed: repo.EmailConfirmedAt != nil, 26 - EmailAuthFactor: false, // TODO: todo todo 27 Active: repo.Active(), 28 Status: repo.Status(), 29 })
··· 23 Did: repo.Repo.Did, 24 Email: repo.Email, 25 EmailConfirmed: repo.EmailConfirmedAt != nil, 26 + EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 27 Active: repo.Active(), 28 Status: repo.Status(), 29 })
+29 -7
server/handle_server_update_email.go
··· 11 type ComAtprotoServerUpdateEmailRequest struct { 12 Email string `json:"email" validate:"required"` 13 EmailAuthFactor bool `json:"emailAuthFactor"` 14 - Token string `json:"token" validate:"required"` 15 } 16 17 func (s *Server) handleServerUpdateEmail(e echo.Context) error { ··· 30 return helpers.InputError(e, nil) 31 } 32 33 - if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 34 return helpers.InvalidTokenError(e) 35 } 36 37 - if *urepo.EmailUpdateCode != req.Token { 38 - return helpers.InvalidTokenError(e) 39 } 40 41 - if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { 42 - return helpers.ExpiredTokenError(e) 43 } 44 45 - if err := s.db.Exec(ctx, "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil { 46 logger.Error("error updating repo", "error", err) 47 return helpers.ServerError(e, nil) 48 }
··· 11 type ComAtprotoServerUpdateEmailRequest struct { 12 Email string `json:"email" validate:"required"` 13 EmailAuthFactor bool `json:"emailAuthFactor"` 14 + Token string `json:"token"` 15 } 16 17 func (s *Server) handleServerUpdateEmail(e echo.Context) error { ··· 30 return helpers.InputError(e, nil) 31 } 32 33 + // To disable email auth factor a token is required. 34 + // To enable email auth factor a token is not required. 35 + // If updating an email address, a token will be sent anyway 36 + if urepo.TwoFactorType != models.TwoFactorTypeNone && req.EmailAuthFactor == false && req.Token == "" { 37 return helpers.InvalidTokenError(e) 38 } 39 40 + if req.Token != "" { 41 + if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 42 + return helpers.InvalidTokenError(e) 43 + } 44 + 45 + if *urepo.EmailUpdateCode != req.Token { 46 + return helpers.InvalidTokenError(e) 47 + } 48 + 49 + if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { 50 + return helpers.ExpiredTokenError(e) 51 + } 52 } 53 54 + twoFactorType := models.TwoFactorTypeNone 55 + if req.EmailAuthFactor { 56 + twoFactorType = models.TwoFactorTypeEmail 57 } 58 59 + query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, two_factor_type = ?, email = ?" 60 + 61 + if urepo.Email != req.Email { 62 + query += ",email_confirmed_at = NULL" 63 + } 64 + 65 + query += " WHERE did = ?" 66 + 67 + if err := s.db.Exec(ctx, query, nil, twoFactorType, req.Email, urepo.Repo.Did).Error; err != nil { 68 logger.Error("error updating repo", "error", err) 69 return helpers.ServerError(e, nil) 70 }
+19
server/mail.go
··· 96 97 return nil 98 }
··· 96 97 return nil 98 } 99 + 100 + func (s *Server) sendTwoFactorCode(email, handle, code string) error { 101 + if s.mail == nil { 102 + return nil 103 + } 104 + 105 + s.mailLk.Lock() 106 + defer s.mailLk.Unlock() 107 + 108 + s.mail.To(email) 109 + s.mail.Subject("2FA code for " + s.config.Hostname) 110 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your 2FA code is %s. This code will expire in ten minutes.", handle, code)) 111 + 112 + if err := s.mail.Send(); err != nil { 113 + return err 114 + } 115 + 116 + return nil 117 + }
+4
server/templates/signin.html
··· 26 type="password" 27 placeholder="Password" 28 /> 29 <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 30 <button class="primary" type="submit" value="Login">Login</button> 31 </form>
··· 26 type="password" 27 placeholder="Password" 28 /> 29 + {{ if .flashes.tokenrequired }} 30 + <br /> 31 + <input name="token" id="token" placeholder="Enter your 2FA token" /> 32 + {{ end }} 33 <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 34 <button class="primary" type="submit" value="Login">Login</button> 35 </form>