diff --git a/README.md b/README.md index 3f8e51b..e80451a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Just because something is implemented doesn't mean it is finisehd. Tons of these - [ ] com.atproto.server.activateAccount - [ ] com.atproto.server.checkAccountStatus -- [ ] com.atproto.server.confirmEmail +- [x] com.atproto.server.confirmEmail - [x] com.atproto.server.createAccount - [ ] com.atproto.server.deactivateAccount - [ ] com.atproto.server.deleteAccount @@ -43,7 +43,7 @@ Just because something is implemented doesn't mean it is finisehd. Tons of these - [ ] com.atproto.server.listAppPasswords - [x] com.atproto.server.refreshSession - [ ] com.atproto.server.requestAccountDelete -- [ ] com.atproto.server.requestEmailConfirmation +- [x] com.atproto.server.requestEmailConfirmation - [ ] com.atproto.server.requestEmailUpdate - [ ] com.atproto.server.requestPasswordReset - [ ] com.atproto.server.reserveSigningKey diff --git a/models/models.go b/models/models.go index 25f0449..898dc9e 100644 --- a/models/models.go +++ b/models/models.go @@ -8,16 +8,21 @@ import ( ) type Repo struct { - Did string `gorm:"primaryKey"` - CreatedAt time.Time - Email string `gorm:"uniqueIndex"` - EmailConfirmedAt *time.Time - EmailVerificationCode *string - Password string - SigningKey []byte - Rev string - Root []byte - Preferences []byte + Did string `gorm:"primaryKey"` + CreatedAt time.Time + Email string `gorm:"uniqueIndex"` + EmailConfirmedAt *time.Time + EmailVerificationCode *string + EmailVerificationCodeExpiresAt *time.Time + EmailUpdateCode *string + EmailUpdateCodeExpiresAt *time.Time + PasswordResetCode *string + PasswordResetCodeExpiresAt *time.Time + Password string + SigningKey []byte + Rev string + Root []byte + Preferences []byte } func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { diff --git a/server/handle_server_confirm_email.go b/server/handle_server_confirm_email.go index a91c618..734941c 100644 --- a/server/handle_server_confirm_email.go +++ b/server/handle_server_confirm_email.go @@ -27,7 +27,7 @@ func (s *Server) handleServerConfirmEmail(e echo.Context) error { return helpers.InputError(e, nil) } - if urepo.EmailVerificationCode == nil { + if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil { return helpers.InputError(e, to.StringPtr("ExpiredToken")) } @@ -35,9 +35,13 @@ func (s *Server) handleServerConfirmEmail(e echo.Context) error { return helpers.InputError(e, to.StringPtr("InvalidToken")) } + if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) { + return helpers.InputError(e, to.StringPtr("ExpiredToken")) + } + now := time.Now().UTC() - if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_confirmed_at = ? WHERE did = ?", now, urepo.Repo.Did).Error; err != nil { + if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", now, urepo.Repo.Did).Error; err != nil { s.logger.Error("error updating user", "error", err) return helpers.ServerError(e, nil) } diff --git a/server/handle_server_request_email_confirmation.go b/server/handle_server_request_email_confirmation.go index 52445ed..1f7b27a 100644 --- a/server/handle_server_request_email_confirmation.go +++ b/server/handle_server_request_email_confirmation.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "time" "github.com/Azure/go-autorest/autorest/to" "github.com/haileyok/cocoon/internal/helpers" @@ -17,8 +18,9 @@ func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error { } code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6)) + eat := time.Now().Add(10 * time.Minute).UTC() - if err := s.db.Exec("UPDATE repos SET email_verification_code = ? WHERE did = ?", code, urepo.Repo.Did).Error; err != nil { + if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { s.logger.Error("error updating user", "error", err) return helpers.ServerError(e, nil) } diff --git a/server/handle_server_request_email_update.go b/server/handle_server_request_email_update.go new file mode 100644 index 0000000..7728b96 --- /dev/null +++ b/server/handle_server_request_email_update.go @@ -0,0 +1,29 @@ +package server + +import ( + "fmt" + "time" + + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" +) + +func (s *Server) handleServerRequestEmailUpdate(e echo.Context) error { + urepo := e.Get("repo").(*models.RepoActor) + + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6)) + eat := time.Now().Add(10 * time.Minute).UTC() + + if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating repo", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.sendEmailUpdate(urepo.Email, urepo.Handle, code); err != nil { + s.logger.Error("error sending email", "error", err) + return helpers.ServerError(e, nil) + } + + return e.NoContent(200) +} diff --git a/server/handle_server_request_password_reset.go b/server/handle_server_request_password_reset.go new file mode 100644 index 0000000..f481073 --- /dev/null +++ b/server/handle_server_request_password_reset.go @@ -0,0 +1,29 @@ +package server + +import ( + "fmt" + "time" + + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" +) + +func (s *Server) handleServerRequestPasswordReset(e echo.Context) error { + urepo := e.Get("repo").(*models.RepoActor) + + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6)) + eat := time.Now().Add(10 * time.Minute).UTC() + + if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating repo", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.sendPasswordReset(urepo.Email, urepo.Handle, code); err != nil { + s.logger.Error("error sending email", "error", err) + return helpers.ServerError(e, nil) + } + + return e.NoContent(200) +} diff --git a/server/handle_server_reset_password.go b/server/handle_server_reset_password.go new file mode 100644 index 0000000..1955652 --- /dev/null +++ b/server/handle_server_reset_password.go @@ -0,0 +1,55 @@ +package server + +import ( + "time" + + "github.com/Azure/go-autorest/autorest/to" + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +type ComAtprotoServerResetPasswordRequest struct { + Token string `json:"token" validate:"required"` + Password string `json:"password" validate:"required"` +} + +func (s *Server) handleServerResetPassword(e echo.Context) error { + urepo := e.Get("repo").(*models.RepoActor) + + var req ComAtprotoServerResetPasswordRequest + if err := e.Bind(&req); err != nil { + s.logger.Error("error binding", "error", err) + return helpers.ServerError(e, nil) + } + + if err := e.Validate(req); err != nil { + return helpers.InputError(e, nil) + } + + if urepo.PasswordResetCode == nil || urepo.PasswordResetCodeExpiresAt == nil { + return helpers.InputError(e, to.StringPtr("InvalidToken")) + } + + if *urepo.PasswordResetCode != req.Token { + return helpers.InputError(e, to.StringPtr("InvalidToken")) + } + + if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) { + return helpers.InputError(e, to.StringPtr("ExpiredToken")) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) + if err != nil { + s.logger.Error("error creating hash", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", hash, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating repo", "error", err) + return helpers.ServerError(e, nil) + } + + return e.NoContent(200) +} diff --git a/server/handle_server_update_email.go b/server/handle_server_update_email.go new file mode 100644 index 0000000..5793c36 --- /dev/null +++ b/server/handle_server_update_email.go @@ -0,0 +1,49 @@ +package server + +import ( + "time" + + "github.com/Azure/go-autorest/autorest/to" + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" +) + +type ComAtprotoServerUpdateEmailRequest struct { + Email string `json:"email" validate:"required"` + EmailAuthFactor bool `json:"emailAuthFactor"` + Token string `json:"token" validate:"required"` +} + +func (s *Server) handleServerUpdateEmail(e echo.Context) error { + urepo := e.Get("repo").(*models.RepoActor) + + var req ComAtprotoServerUpdateEmailRequest + if err := e.Bind(&req); err != nil { + s.logger.Error("error binding", "error", err) + return helpers.ServerError(e, nil) + } + + if err := e.Validate(req); err != nil { + return helpers.InputError(e, nil) + } + + if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { + return helpers.InputError(e, to.StringPtr("InvalidToken")) + } + + if *urepo.EmailUpdateCode != req.Token { + return helpers.InputError(e, to.StringPtr("InvalidToken")) + } + + if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { + return helpers.InputError(e, to.StringPtr("ExpiredToken")) + } + + if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email = ? WHERE did = ?", req.Email, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating repo", "error", err) + return helpers.ServerError(e, nil) + } + + return e.NoContent(200) +} diff --git a/server/mail.go b/server/mail.go index 1e08c01..2797af2 100644 --- a/server/mail.go +++ b/server/mail.go @@ -23,7 +23,22 @@ func (s *Server) sendPasswordReset(email, handle, code string) error { s.mail.To(email) s.mail.Subject("Password reset for " + s.config.Hostname) - s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s.", handle, code)) + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s. This code will expire in ten minutes.", handle, code)) + + if err := s.mail.Send(); err != nil { + return err + } + + return nil +} + +func (s *Server) sendEmailUpdate(email, handle, code string) error { + s.mailLk.Lock() + defer s.mailLk.Unlock() + + s.mail.To(email) + s.mail.Subject("Email update for " + s.config.Hostname) + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email update code is %s. This code will expire in ten minutes.", handle, code)) if err := s.mail.Send(); err != nil { return err @@ -38,7 +53,7 @@ func (s *Server) sendEmailVerification(email, handle, code string) error { s.mail.To(email) s.mail.Subject("Email verification for " + s.config.Hostname) - s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s", handle, code)) + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s. This code will expire in ten minutes.", handle, code)) if err := s.mail.Send(); err != nil { return err diff --git a/server/server.go b/server/server.go index f252f55..027ebea 100644 --- a/server/server.go +++ b/server/server.go @@ -383,6 +383,10 @@ func (s *Server) addRoutes() { s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware) s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset, s.handleSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware) // repo s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)