1
0

Refactor user email confirmation + password reset handling (#919)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/919
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad
2021-07-13 20:56:02 +00:00
parent d5d4d8b6ed
commit 4216ed7277
25 changed files with 436 additions and 134 deletions

View File

@ -36,5 +36,6 @@ func GetTables() []interface{} {
return []interface{}{
&User{},
&TOTP{},
&Token{},
}
}

View File

@ -23,8 +23,9 @@ import (
// EmailConfirmNotification represents a EmailConfirmNotification notification
type EmailConfirmNotification struct {
User *User
IsNew bool
User *User
IsNew bool
ConfirmToken string
}
// ToMail returns the mail notification for EmailConfirmNotification
@ -45,7 +46,7 @@ func (n *EmailConfirmNotification) ToMail() *notifications.Mail {
return nn.
Line("To confirm your email address, click the link below:").
Action("Confirm your email address", config.ServiceFrontendurl.GetString()+"?userEmailConfirm="+n.User.EmailConfirmToken).
Action("Confirm your email address", config.ServiceFrontendurl.GetString()+"?userEmailConfirm="+n.ConfirmToken).
Line("Have a nice day!")
}
@ -85,7 +86,8 @@ func (n *PasswordChangedNotification) Name() string {
// ResetPasswordNotification represents a ResetPasswordNotification notification
type ResetPasswordNotification struct {
User *User
User *User
Token *Token
}
// ToMail returns the mail notification for ResetPasswordNotification
@ -94,7 +96,8 @@ func (n *ResetPasswordNotification) ToMail() *notifications.Mail {
Subject("Reset your password on Vikunja").
Greeting("Hi "+n.User.GetName()+",").
Line("To reset your password, click the link below:").
Action("Reset your password", config.ServiceFrontendurl.GetString()+"?userPasswordReset="+n.User.PasswordResetToken).
Action("Reset your password", config.ServiceFrontendurl.GetString()+"?userPasswordReset="+n.Token.Token).
Line("This link will be valid for 24 hours.").
Line("Have a nice day!")
}

View File

@ -34,7 +34,7 @@ func InitTests() {
log.Fatal(err)
}
err = db.InitTestFixtures("users")
err = db.InitTestFixtures("users", "user_tokens")
if err != nil {
log.Fatal(err)
}

104
pkg/user/token.go Normal file
View File

@ -0,0 +1,104 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/utils"
"xorm.io/xorm"
)
// TokenKind represents a user token kind
type TokenKind int
const (
TokenUnknown TokenKind = iota
TokenPasswordReset
TokenEmailConfirm
tokenSize = 64
)
// Token is a token a user can use to do things like verify their email or resetting their password
type Token struct {
ID int64 `xorm:"bigint autoincr not null unique pk"`
UserID int64 `xorm:"not null"`
Token string `xorm:"not null"`
Kind TokenKind `xorm:"not null"`
Created time.Time `xorm:"created not null"`
}
// TableName returns the real table name for user tokens
func (t *Token) TableName() string {
return "user_tokens"
}
func generateNewToken(s *xorm.Session, u *User, kind TokenKind) (token *Token, err error) {
token = &Token{
UserID: u.ID,
Kind: kind,
Token: utils.MakeRandomString(tokenSize),
}
_, err = s.Insert(token)
return
}
func getToken(s *xorm.Session, token string, kind TokenKind) (t *Token, err error) {
t = &Token{}
has, err := s.Where("kind = ? AND token = ?", kind, token).
Get(t)
if err != nil || !has {
return nil, err
}
return
}
func removeTokens(s *xorm.Session, u *User, kind TokenKind) (err error) {
_, err = s.Where("user_id = ? AND kind = ?", u.ID, kind).
Delete(&Token{})
return
}
// RegisterTokenCleanupCron registers a cron function to clean up all password reset tokens older than 24 hours
func RegisterTokenCleanupCron() {
const logPrefix = "[User Token Cleanup Cron] "
err := cron.Schedule("0 * * * *", func() {
s := db.NewSession()
defer s.Close()
deleted, err := s.
Where("created > ? AND kind = ?", time.Now().Add(time.Hour*24*-1), TokenPasswordReset).
Delete(&Token{})
if err != nil {
log.Errorf(logPrefix+"Error removing old password reset tokens: %s", err)
return
}
if deleted > 0 {
log.Debugf(logPrefix+"Deleted %d old password reset tokens", deleted)
}
})
if err != nil {
log.Fatalf("Could not register token cleanup cron: %s", err)
}
}

View File

@ -19,7 +19,6 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"xorm.io/xorm"
)
@ -52,26 +51,35 @@ func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {
return
}
update.User.IsActive = false
update.User.Email = update.NewEmail
update.User.EmailConfirmToken = utils.MakeRandomString(64)
// Send the confirmation mail
if !config.MailerEnabled.GetBool() {
_, err = s.
Where("id = ?", update.User.ID).
Cols("email").
Update(update.User)
return
}
update.User.Status = StatusEmailConfirmationRequired
token, err := generateNewToken(s, update.User, TokenEmailConfirm)
if err != nil {
return
}
_, err = s.
Where("id = ?", update.User.ID).
Cols("email", "is_active", "email_confirm_token").
Cols("email", "is_active"). // TODO: Status change
Update(update.User)
if err != nil {
return
}
// Send the confirmation mail
if !config.MailerEnabled.GetBool() {
return
}
// Send the user a mail with a link to confirm the mail
n := &EmailConfirmNotification{
User: update.User,
IsNew: false,
User: update.User,
IsNew: false,
ConfirmToken: token.Token,
}
err = notifications.Notify(update.User, n)

View File

@ -44,6 +44,27 @@ type Login struct {
TOTPPasscode string `json:"totp_passcode"`
}
type Status int
func (s Status) String() string {
switch s {
case StatusActive:
return "Active"
case StatusEmailConfirmationRequired:
return "Email Confirmation required"
case StatusDisabled:
return "Disabled"
}
return "Unknown"
}
const (
StatusActive = iota
StatusEmailConfirmationRequired
StatusDisabled
)
// User holds information about an user
type User struct {
// The unique, numeric id of this user.
@ -54,11 +75,9 @@ type User struct {
Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"1" maxLength:"250"`
Password string `xorm:"varchar(250) null" json:"-"`
// The user's email address.
Email string `xorm:"varchar(250) null" json:"email,omitempty" valid:"email,length(0|250)" maxLength:"250"`
IsActive bool `xorm:"null" json:"-"`
Email string `xorm:"varchar(250) null" json:"email,omitempty" valid:"email,length(0|250)" maxLength:"250"`
PasswordResetToken string `xorm:"varchar(450) null" json:"-"`
EmailConfirmToken string `xorm:"varchar(450) null" json:"-"`
Status Status `xorm:"default 0" json:"-"`
AvatarProvider string `xorm:"varchar(255) null" json:"-"`
AvatarFileID int64 `xorm:"null" json:"-"`
@ -255,7 +274,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) {
}
// The user is invalid if they need to verify their email address
if !user.IsActive {
if user.Status == StatusEmailConfirmationRequired {
return &User{}, ErrEmailNotConfirmed{UserID: user.ID}
}

View File

@ -20,7 +20,6 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
)
@ -54,14 +53,7 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) {
}
}
user.IsActive = true
if config.MailerEnabled.GetBool() && user.Issuer == issuerLocal {
// The new user should not be activated until it confirms his mail address
user.IsActive = false
// Generate a confirm token
user.EmailConfirmToken = utils.MakeRandomString(60)
}
user.Status = StatusActive
user.AvatarProvider = "initials"
// Insert it
@ -84,13 +76,28 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) {
}
// Dont send a mail if no mailer is configured
if !config.MailerEnabled.GetBool() {
if !config.MailerEnabled.GetBool() || user.Issuer != issuerLocal {
return newUserOut, err
}
user.Status = StatusEmailConfirmationRequired
token, err := generateNewToken(s, user, TokenEmailConfirm)
if err != nil {
return nil, err
}
_, err = s.
Where("id = ?", user.ID).
Cols("email", "is_active").
Update(user)
if err != nil {
return
}
n := &EmailConfirmNotification{
User: user,
IsNew: false,
User: user,
IsNew: true,
ConfirmToken: token.Token,
}
err = notifications.Notify(user, n)

View File

@ -16,7 +16,9 @@
package user
import "xorm.io/xorm"
import (
"xorm.io/xorm"
)
// EmailConfirm holds the token to confirm a mail address
type EmailConfirm struct {
@ -32,24 +34,27 @@ func ConfirmEmail(s *xorm.Session, c *EmailConfirm) (err error) {
return ErrInvalidEmailConfirmToken{}
}
// Check if the token is valid
user := User{}
has, err := s.
Where("email_confirm_token = ?", c.Token).
Get(&user)
token, err := getToken(s, c.Token, TokenEmailConfirm)
if err != nil {
return
}
if token == nil {
return ErrInvalidEmailConfirmToken{Token: c.Token}
}
user, err := GetUserByID(s, token.UserID)
if err != nil {
return
}
if !has {
return ErrInvalidEmailConfirmToken{Token: c.Token}
user.Status = StatusActive
err = removeTokens(s, user, TokenEmailConfirm)
if err != nil {
return
}
user.IsActive = true
user.EmailConfirmToken = ""
_, err = s.
Where("id = ?", user.ID).
Cols("is_active", "email_confirm_token").
Update(&user)
Cols("is_active").
Update(user)
return
}

View File

@ -19,7 +19,6 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"xorm.io/xorm"
)
@ -44,16 +43,17 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
}
// Check if we have a token
user := &User{}
exists, err := s.
Where("password_reset_token = ?", reset.Token).
Get(user)
token, err := getToken(s, reset.Token, TokenPasswordReset)
if err != nil {
return
return err
}
if token == nil {
return ErrInvalidPasswordResetToken{Token: reset.Token}
}
if !exists {
return ErrInvalidPasswordResetToken{Token: reset.Token}
user, err := GetUserByID(s, token.UserID)
if err != nil {
return
}
// Hash the password
@ -62,17 +62,20 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
return
}
// Save it
user.PasswordResetToken = ""
err = removeTokens(s, user, TokenEmailConfirm)
if err != nil {
return
}
_, err = s.
Cols("password", "password_reset_token").
Cols("password").
Where("id = ?", user.ID).
Update(user)
if err != nil {
return
}
// Dont send a mail if we're testing
// Dont send a mail if no mailer is configured
if !config.MailerEnabled.GetBool() {
return
}
@ -108,24 +111,19 @@ func RequestUserPasswordResetTokenByEmail(s *xorm.Session, tr *PasswordTokenRequ
// RequestUserPasswordResetToken sends a user a password reset email.
func RequestUserPasswordResetToken(s *xorm.Session, user *User) (err error) {
// Generate a token and save it
user.PasswordResetToken = utils.MakeRandomString(400)
// Save it
_, err = s.
Where("id = ?", user.ID).
Update(user)
token, err := generateNewToken(s, user, TokenPasswordReset)
if err != nil {
return
}
// Dont send a mail if we're testing
// Dont send a mail if no mailer is configured
if !config.MailerEnabled.GetBool() {
return
}
n := &ResetPasswordNotification{
User: user,
User: user,
Token: token,
}
err = notifications.Notify(user, n)

View File

@ -322,7 +322,6 @@ func TestUpdateUser(t *testing.T) {
}
func TestUpdateUserPassword(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()