diff --git a/cmd/web.go b/cmd/web.go index 27879e4a3..df7c96b12 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -241,6 +241,8 @@ func runWeb(*cli.Context) { m.Get("", user.Settings) m.Post("", bindIgnErr(auth.UpdateProfileForm{}), user.SettingsPost) m.Post("/avatar", binding.MultipartForm(auth.UploadAvatarForm{}), user.SettingsAvatar) + m.Get("/email", user.SettingsEmails) + m.Post("/email", bindIgnErr(auth.AddEmailForm{}), user.SettingsEmailPost) m.Get("/password", user.SettingsPassword) m.Post("/password", bindIgnErr(auth.ChangePasswordForm{}), user.SettingsPasswordPost) m.Get("/ssh", user.SettingsSSHKeys) @@ -252,6 +254,7 @@ func runWeb(*cli.Context) { m.Group("/user", func() { // r.Get("/feeds", binding.Bind(auth.FeedsForm{}), user.Feeds) m.Any("/activate", user.Activate) + m.Any("/activate_email", user.ActivateEmail) m.Get("/email2user", user.Email2User) m.Get("/forget_password", user.ForgotPasswd) m.Post("/forget_password", user.ForgotPasswdPost) diff --git a/models/models.go b/models/models.go index 92849f585..677f9ba9d 100644 --- a/models/models.go +++ b/models/models.go @@ -45,7 +45,7 @@ func init() { new(Issue), new(Comment), new(Attachment), new(IssueUser), new(Label), new(Milestone), new(Mirror), new(Release), new(LoginSource), new(Webhook), new(UpdateTask), new(HookTask), new(Team), new(OrgUser), new(TeamUser), - new(Notice)) + new(Notice), new(EmailAddress)) } func LoadModelsConfig() { diff --git a/models/user.go b/models/user.go index 729f2c0c7..2537a3e69 100644 --- a/models/user.go +++ b/models/user.go @@ -42,6 +42,8 @@ var ( ErrUserNotExist = errors.New("User does not exist") ErrUserNotKeyOwner = errors.New("User does not the owner of public key") ErrEmailAlreadyUsed = errors.New("E-mail already used") + ErrEmailNotExist = errors.New("E-mail does not exist") + ErrEmailNotActivated = errors.New("E-mail address has not been activated") ErrUserNameIllegal = errors.New("User name contains illegal characters") ErrLoginSourceNotExist = errors.New("Login source does not exist") ErrLoginSourceNotActived = errors.New("Login source is not actived") @@ -50,10 +52,11 @@ var ( // User represents the object of individual and member of organization. type User struct { - Id int64 - LowerName string `xorm:"UNIQUE NOT NULL"` - Name string `xorm:"UNIQUE NOT NULL"` - FullName string + Id int64 + LowerName string `xorm:"UNIQUE NOT NULL"` + Name string `xorm:"UNIQUE NOT NULL"` + FullName string + // Email is the primary email address (to be used for communication). Email string `xorm:"UNIQUE(s) NOT NULL"` Passwd string `xorm:"NOT NULL"` LoginType LoginType @@ -93,6 +96,16 @@ type User struct { Members []*User `xorm:"-"` } +// EmailAdresses is the list of all email addresses of a user. Can contain the +// primary email address, but is not obligatory +type EmailAddress struct { + Id int64 + Uid int64 `xorm:"INDEX NOT NULL"` + Email string `xorm:"UNIQUE NOT NULL"` + IsActivated bool + IsPrimary bool `xorm:"-"` +} + // DashboardLink returns the user dashboard page link. func (u *User) DashboardLink() string { if u.IsOrganization() { @@ -248,6 +261,9 @@ func IsEmailUsed(email string) (bool, error) { if len(email) == 0 { return false, nil } + if has, err := x.Get(&EmailAddress{Email: email}); has || err != nil { + return has, err + } return x.Get(&User{Email: email}) } @@ -355,6 +371,25 @@ func VerifyUserActiveCode(code string) (user *User) { return nil } +// verify active code when active account +func VerifyActiveEmailCode(code, email string) *EmailAddress { + minutes := setting.Service.ActiveCodeLives + + if user := getVerifyUser(code); user != nil { + // time limit code + prefix := code[:base.TimeLimitCodeLength] + data := com.ToStr(user.Id) + email + user.LowerName + user.Passwd + user.Rands + + if base.VerifyTimeLimitCode(data, minutes, prefix) { + emailAddress := &EmailAddress{Email: email} + if has, _ := x.Get(emailAddress); has { + return emailAddress + } + } + } + return nil +} + // ChangeUserName changes all corresponding setting from old user name to new one. func ChangeUserName(u *User, newUserName string) (err error) { if !IsLegalName(newUserName) { @@ -488,6 +523,10 @@ func DeleteUser(u *User) error { if _, err = x.Delete(&Access{UserName: u.LowerName}); err != nil { return err } + // Delete all alternative email addresses + if _, err = x.Delete(&EmailAddress{Uid: u.Id}); err != nil { + return err + } // Delete all SSH keys. keys := make([]*PublicKey, 0, 10) if err = x.Find(&keys, &PublicKey{OwnerId: u.Id}); err != nil { @@ -508,9 +547,12 @@ func DeleteUser(u *User) error { return err } -// DeleteInactivateUsers deletes all inactivate users. +// DeleteInactivateUsers deletes all inactivate users and email addresses. func DeleteInactivateUsers() error { _, err := x.Where("is_active=?", false).Delete(new(User)) + if err == nil { + _, err = x.Where("is_activated=?", false).Delete(new(EmailAddress)) + } return err } @@ -584,6 +626,117 @@ func GetUserIdsByNames(names []string) []int64 { return ids } +// Get all email addresses +func GetEmailAddresses(uid int64) ([]*EmailAddress, error) { + emails := make([]*EmailAddress, 0, 5) + err := x.Where("owner_id=?", uid).Find(&emails) + if err != nil { + return nil, err + } + + u, err := GetUserById(uid) + if err != nil { + return nil, err + } + + isPrimaryFound := false + + for _, email := range emails { + if email.Email == u.Email { + isPrimaryFound = true + email.IsPrimary = true + } else { + email.IsPrimary = false + } + } + + // We alway want the primary email address displayed, even if it's not in + // the emailaddress table (yet) + if !isPrimaryFound { + emails = append(emails, &EmailAddress{Email: u.Email, IsActivated: true, IsPrimary: true}) + } + return emails, nil +} + +func AddEmailAddress(email *EmailAddress) error { + used, err := IsEmailUsed(email.Email) + if err != nil { + return err + } else if used { + return ErrEmailAlreadyUsed + } + + _, err = x.Insert(email) + return err +} + +func (email *EmailAddress) Activate() error { + email.IsActivated = true + if _, err := x.Id(email.Id).AllCols().Update(email); err != nil { + return err + } + + if user, err := GetUserById(email.Uid); err != nil { + return err + } else { + user.Rands = GetUserSalt() + return UpdateUser(user) + } +} + +func DeleteEmailAddress(email *EmailAddress) error { + has, err := x.Get(email) + if err != nil { + return err + } else if !has { + return ErrEmailNotExist + } + + if _, err = x.Delete(email); err != nil { + return err + } + + return nil + +} + +func MakeEmailPrimary(email *EmailAddress) error { + has, err := x.Get(email) + if err != nil { + return err + } else if !has { + return ErrEmailNotExist + } + + if !email.IsActivated { + return ErrEmailNotActivated + } + + user := &User{Id: email.Uid} + has, err = x.Get(user) + if err != nil { + return err + } else if !has { + return ErrUserNotExist + } + + // Make sure the former primary email doesn't disappear + former_primary_email := &EmailAddress{Email: user.Email} + has, err = x.Get(former_primary_email) + if err != nil { + return err + } else if !has { + former_primary_email.Uid = user.Id + former_primary_email.IsActivated = user.IsActive + x.Insert(former_primary_email) + } + + user.Email = email.Email + _, err = x.Id(user.Id).AllCols().Update(user) + + return err +} + // UserCommit represents a commit with validation of user. type UserCommit struct { User *User @@ -629,14 +782,27 @@ func GetUserByEmail(email string) (*User, error) { if len(email) == 0 { return nil, ErrUserNotExist } + // First try to find the user by primary email user := &User{Email: strings.ToLower(email)} has, err := x.Get(user) if err != nil { return nil, err - } else if !has { - return nil, ErrUserNotExist } - return user, nil + if has { + return user, nil + } + + // Otherwise, check in alternative list for activated email addresses + emailAddress := &EmailAddress{Email: strings.ToLower(email), IsActivated: true} + has, err = x.Get(emailAddress) + if err != nil { + return nil, err + } + if has { + return GetUserById(emailAddress.Uid) + } + + return nil, ErrUserNotExist } // SearchUserByName returns given number of users whose name contains keyword. diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 4dfe2499f..becd5cbca 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -97,6 +97,14 @@ func (f *UploadAvatarForm) Validate(ctx *macaron.Context, errs binding.Errors) b return validate(errs, ctx.Data, f, ctx.Locale) } +type AddEmailForm struct { + Email string `form:"email" binding:"Required;Email;MaxSize(50)"` +} + +func (f *AddEmailForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + type ChangePasswordForm struct { OldPassword string `form:"old_password" binding:"Required;MinSize(6);MaxSize(255)"` Password string `form:"password" binding:"Required;MinSize(6);MaxSize(255)"` diff --git a/modules/mailer/mail.go b/modules/mailer/mail.go index 6c73e7e58..21f33b17e 100644 --- a/modules/mailer/mail.go +++ b/modules/mailer/mail.go @@ -21,6 +21,7 @@ import ( const ( AUTH_ACTIVE base.TplName = "mail/auth/active" + AUTH_ACTIVATE_EMAIL base.TplName = "mail/auth/activate_email" AUTH_REGISTER_SUCCESS base.TplName = "mail/auth/register_success" AUTH_RESET_PASSWORD base.TplName = "mail/auth/reset_passwd" @@ -64,6 +65,17 @@ func CreateUserActiveCode(u *models.User, startInf interface{}) string { return code } +// create a time limit code for user active +func CreateUserEmailActivateCode(u *models.User, e *models.EmailAddress, startInf interface{}) string { + minutes := setting.Service.ActiveCodeLives + data := com.ToStr(u.Id) + e.Email + u.LowerName + u.Passwd + u.Rands + code := base.CreateTimeLimitCode(data, minutes, startInf) + + // add tail hex username + code += hex.EncodeToString([]byte(u.LowerName)) + return code +} + // Send user register mail with active code func SendRegisterMail(r macaron.Render, u *models.User) { code := CreateUserActiveCode(u, nil) @@ -103,6 +115,27 @@ func SendActiveMail(r macaron.Render, u *models.User) { SendAsync(&msg) } +// Send email to verify secondary email. +func SendActivateEmail(r macaron.Render, user *models.User, email *models.EmailAddress) { + code := CreateUserEmailActivateCode(user, email, nil) + + subject := "Verify your e-mail address" + + data := GetMailTmplData(user) + data["Code"] = code + data["Email"] = email.Email + body, err := r.HTMLString(string(AUTH_ACTIVATE_EMAIL), data) + if err != nil { + log.Error(4, "mail.SendActiveMail(fail to render): %v", err) + return + } + + msg := NewMailMessage([]string{email.Email}, subject, body) + msg.Info = fmt.Sprintf("UID: %d, send activate email to %s", user.Id, email.Email) + + SendAsync(&msg) +} + // Send reset password email. func SendResetPasswdMail(r macaron.Render, u *models.User) { code := CreateUserActiveCode(u, nil) diff --git a/routers/user/auth.go b/routers/user/auth.go index e576e6af9..9ed44e353 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -345,6 +345,27 @@ func Activate(ctx *middleware.Context) { ctx.HTML(200, ACTIVATE) } +func ActivateEmail(ctx *middleware.Context) { + code := ctx.Query("code") + email_string := ctx.Query("email") + + // Verify code. + if email := models.VerifyActiveEmailCode(code, email_string); email != nil { + err := email.Activate() + if err != nil { + ctx.Handle(500, "ActivateEmail", err) + } + + log.Trace("Email activated: %s", email.Email) + + ctx.Flash.Success(ctx.Tr("settings.activate_email_success")) + + } + + ctx.Redirect(setting.AppSubUrl + "/user/settings/email") + return +} + func ForgotPasswd(ctx *middleware.Context) { ctx.Data["Title"] = ctx.Tr("auth.forgot_password") diff --git a/routers/user/setting.go b/routers/user/setting.go index 304ddd362..419e84b39 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -14,6 +14,7 @@ import ( "github.com/gogits/gogs/modules/auth" "github.com/gogits/gogs/modules/base" "github.com/gogits/gogs/modules/log" + "github.com/gogits/gogs/modules/mailer" "github.com/gogits/gogs/modules/middleware" "github.com/gogits/gogs/modules/setting" ) @@ -21,6 +22,7 @@ import ( const ( SETTINGS_PROFILE base.TplName = "user/settings/profile" SETTINGS_PASSWORD base.TplName = "user/settings/password" + SETTINGS_EMAILS base.TplName = "user/settings/email" SETTINGS_SSH_KEYS base.TplName = "user/settings/sshkeys" SETTINGS_SOCIAL base.TplName = "user/settings/social" SETTINGS_APPLICATIONS base.TplName = "user/settings/applications" @@ -126,6 +128,112 @@ func SettingsAvatar(ctx *middleware.Context, form auth.UploadAvatarForm) { ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) } +func SettingsEmails(ctx *middleware.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsUserSettings"] = true + ctx.Data["PageIsSettingsEmails"] = true + + var err error + ctx.Data["Emails"], err = models.GetEmailAddresses(ctx.User.Id) + + if err != nil { + ctx.Handle(500, "email.GetEmailAddresses", err) + return + } + + ctx.HTML(200, SETTINGS_EMAILS) +} + +func SettingsEmailPost(ctx *middleware.Context, form auth.AddEmailForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsUserSettings"] = true + ctx.Data["PageIsSettingsEmails"] = true + + var err error + ctx.Data["Emails"], err = models.GetEmailAddresses(ctx.User.Id) + if err != nil { + ctx.Handle(500, "email.GetEmailAddresses", err) + return + } + + // Delete Email address. + if ctx.Query("_method") == "DELETE" { + id := com.StrTo(ctx.Query("id")).MustInt64() + if id <= 0 { + return + } + + if err = models.DeleteEmailAddress(&models.EmailAddress{Id: id}); err != nil { + ctx.Handle(500, "DeleteEmail", err) + } else { + log.Trace("Email address deleted: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubUrl + "/user/settings/email") + } + return + } + + // Make emailaddress primary. + if ctx.Query("_method") == "PRIMARY" { + id := com.StrTo(ctx.Query("id")).MustInt64() + if id <= 0 { + return + } + + if err = models.MakeEmailPrimary(&models.EmailAddress{Id: id}); err != nil { + ctx.Handle(500, "MakeEmailPrimary", err) + } else { + log.Trace("Email made primary: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubUrl + "/user/settings/email") + } + return + } + + // Add Email address. + if ctx.Req.Method == "POST" { + if ctx.HasError() { + ctx.HTML(200, SETTINGS_EMAILS) + return + } + + cleanEmail := strings.Replace(form.Email, "\n", "", -1) + e := &models.EmailAddress{ + Uid: ctx.User.Id, + Email: cleanEmail, + IsActivated: !setting.Service.RegisterEmailConfirm, + } + + if err := models.AddEmailAddress(e); err != nil { + if err == models.ErrEmailAlreadyUsed { + ctx.RenderWithErr(ctx.Tr("form.email_has_been_used"), SETTINGS_EMAILS, &form) + return + } + ctx.Handle(500, "email.AddEmailAddress", err) + return + } else { + + // Send confirmation e-mail + if setting.Service.RegisterEmailConfirm { + mailer.SendActivateEmail(ctx.Render, ctx.User, e) + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error(4, "Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Success(ctx.Tr("settings.add_email_success_confirmation_email_sent")) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", e.Email) + + ctx.Redirect(setting.AppSubUrl + "/user/settings/email") + return + } + + } + + ctx.HTML(200, SETTINGS_EMAILS) +} + func SettingsPassword(ctx *middleware.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsUserSettings"] = true diff --git a/templates/mail/auth/activate_email.tmpl b/templates/mail/auth/activate_email.tmpl new file mode 100644 index 000000000..24bbf89d0 --- /dev/null +++ b/templates/mail/auth/activate_email.tmpl @@ -0,0 +1,30 @@ + +
Please click the following link to verify your e-mail address within {{.ActiveCodeLives}} hours.
++ {{.AppUrl}}user/activate_email?code={{.Code}}&email={{.Email}} +
+Not working? Try copying and pasting it to your browser.
+