// 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 . package user import ( "errors" "fmt" "reflect" "strconv" "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/web" "github.com/golang-jwt/jwt/v4" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" "xorm.io/xorm" ) // Login Object to recive user credentials in JSON format type Login struct { // The username used to log in. Username string `json:"username"` // The password for the user. Password string `json:"password"` // The totp passcode of a user. Only needs to be provided when enabled. TOTPPasscode string `json:"totp_passcode"` // If true, the token returned will be valid a lot longer than default. Useful for "remember me" style logins. LongToken bool `json:"long_token"` } 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. ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` // The full name of the user. Name string `xorm:"text null" json:"name"` // The username of the user. Is always unique. 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"` Status Status `xorm:"default 0" json:"-"` AvatarProvider string `xorm:"varchar(255) null" json:"-"` AvatarFileID int64 `xorm:"null" json:"-"` // Issuer and Subject contain the issuer and subject from the source the user authenticated with. Issuer string `xorm:"text null" json:"-"` Subject string `xorm:"text null" json:"-"` EmailRemindersEnabled bool `xorm:"bool default true" json:"-"` DiscoverableByName bool `xorm:"bool default false index" json:"-"` DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` OverdueTasksRemindersEnabled bool `xorm:"bool default true index" json:"-"` OverdueTasksRemindersTime string `xorm:"varchar(5) not null default '09:00'" json:"-"` DefaultListID int64 `xorm:"bigint null index" json:"-"` WeekStart int `xorm:"null" json:"-"` Language string `xorm:"varchar(50) null" json:"-"` Timezone string `xorm:"varchar(255) null" json:"-"` DeletionScheduledAt time.Time `xorm:"datetime null" json:"-"` DeletionLastReminderSent time.Time `xorm:"datetime null" json:"-"` ExportFileID int64 `xorm:"bigint null" json:"-"` // A timestamp when this task was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this task was last updated. You cannot change this value. Updated time.Time `xorm:"updated not null" json:"updated"` web.Auth `xorm:"-" json:"-"` } // RouteForMail routes all notifications for a user to its email address func (u *User) RouteForMail() (string, error) { if u.Email == "" { s := db.NewSession() defer s.Close() user, err := getUser(s, &User{ID: u.ID}, true) if err != nil { return "", err } return user.Email, nil } return u.Email, nil } // RouteForDB routes all notifications for a user to their id func (u *User) RouteForDB() int64 { return u.ID } // GetID implements the Auth interface func (u *User) GetID() int64 { return u.ID } // TableName returns the table name for users func (User) TableName() string { return "users" } // GetName returns the name if the user has one and the username otherwise. func (u *User) GetName() string { if u.Name != "" { return u.Name } return u.Username } // GetNameAndFromEmail returns the name and email address for a user. Useful to use in notifications. func (u *User) GetNameAndFromEmail() string { return u.GetName() + " via Vikunja <" + config.MailerFromEmail.GetString() + ">" } func (u *User) GetFailedTOTPAttemptsKey() string { return "failed_totp_attempts_" + strconv.FormatInt(u.ID, 10) } func (u *User) GetFailedPasswordAttemptsKey() string { return "failed_password_attempts_" + strconv.FormatInt(u.ID, 10) } // GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type // is not a user object func GetFromAuth(a web.Auth) (*User, error) { u, is := a.(*User) if !is { return &User{}, fmt.Errorf("user is not user element, is %s", reflect.TypeOf(a)) } return u, nil } // APIUserPassword represents a user object without timestamps and a json password field. type APIUserPassword struct { // The unique, numeric id of this user. ID int64 `json:"id"` // The username of the username. Is always unique. Username string `json:"username" valid:"length(3|250)" minLength:"3" maxLength:"250"` // The user's password in clear text. Only used when registering the user. Password string `json:"password" valid:"length(8|250)" minLength:"8" maxLength:"250"` // The user's email address Email string `json:"email" valid:"email,length(0|250)" maxLength:"250"` } // APIFormat formats an API User into a normal user struct func (apiUser *APIUserPassword) APIFormat() *User { return &User{ ID: apiUser.ID, Username: apiUser.Username, Password: apiUser.Password, Email: apiUser.Email, } } // GetUserByID gets informations about a user by its ID func GetUserByID(s *xorm.Session, id int64) (user *User, err error) { // Apparently xorm does otherwise look for all users but return only one, which leads to returning one even if the ID is 0 if id < 1 { return &User{}, ErrUserDoesNotExist{} } return getUser(s, &User{ID: id}, false) } // GetUserByUsername gets a user from its user name. This is an extra function to be able to add an extra error check. func GetUserByUsername(s *xorm.Session, username string) (user *User, err error) { if username == "" { return &User{}, ErrUserDoesNotExist{} } return getUser(s, &User{Username: username}, false) } // GetUsersByUsername returns a slice of users with the provided usernames func GetUsersByUsername(s *xorm.Session, usernames []string, withEmails bool) (users map[int64]*User, err error) { if len(usernames) == 0 { return } users = make(map[int64]*User) err = s.In("username", usernames).Find(&users) if err != nil { return } if !withEmails { for _, u := range users { u.Email = "" } } return } // GetUserWithEmail returns a user object with email func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) { return getUser(s, user, true) } // GetUsersByIDs returns a map of users from a slice of user ids func GetUsersByIDs(s *xorm.Session, userIDs []int64) (users map[int64]*User, err error) { users = make(map[int64]*User) if len(userIDs) == 0 { return users, nil } err = s.In("id", userIDs).Find(&users) if err != nil { return } // Obfuscate all user emails for _, u := range users { u.Email = "" } return } // getUser is a small helper function to avoid having duplicated code for almost the same use case func getUser(s *xorm.Session, user *User, withEmail bool) (userOut *User, err error) { userOut = &User{} // To prevent a panic if user is nil *userOut = *user exists, err := s.Get(userOut) if err != nil { return nil, err } if !exists { return &User{}, ErrUserDoesNotExist{UserID: user.ID} } if !withEmail { userOut.Email = "" } if userOut.OverdueTasksRemindersTime == "" { userOut.OverdueTasksRemindersTime = "9:00" } return userOut, err } func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User, err error) { u = &User{} exists, err := s. Where("username = ? OR email = ?", usernameOrEmail, usernameOrEmail). Get(u) if err != nil { return nil, err } if !exists { return nil, ErrUserDoesNotExist{} } u.Email = "" return } // CheckUserCredentials checks user credentials func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { // Check if we have any credentials if u.Password == "" || u.Username == "" { return nil, ErrNoUsernamePassword{} } // Check if the user exists user, err := getUserByUsernameOrEmail(s, u.Username) if err != nil { // hashing the password takes a long time, so we hash something to not make it clear if the username was wrong _, _ = bcrypt.GenerateFromPassword([]byte(u.Username), 14) return nil, ErrWrongUsernameOrPassword{} } if user.Issuer != IssuerLocal { return user, &ErrAccountIsNotLocal{UserID: user.ID} } // The user is invalid if they need to verify their email address if user.Status == StatusEmailConfirmationRequired { return &User{}, ErrEmailNotConfirmed{UserID: user.ID} } // Check the users password err = CheckUserPassword(user, u.Password) if err != nil { if IsErrWrongUsernameOrPassword(err) { handleFailedPassword(user) } return user, err } return user, nil } func handleFailedPassword(user *User) { key := user.GetFailedPasswordAttemptsKey() err := keyvalue.IncrBy(key, 1) if err != nil { log.Errorf("Could not set failed password attempts: %s", err) return } a, _, err := keyvalue.Get(key) if err != nil { log.Errorf("Could get failed password attempts for user %d: %s", user.ID, err) return } attempts := a.(int64) if attempts != 3 { return } err = notifications.Notify(user, &FailedLoginAttemptNotification{ User: user, }) if err != nil { log.Errorf("Could not send invalid password mail to user: %s", err) return } err = keyvalue.Del(key) if err != nil { log.Errorf("Could not remove failed password attempts: %s", err) return } } // CheckUserPassword checks and verifies a user's password. The user object needs to contain the hashed password from the database. func CheckUserPassword(user *User, password string) error { err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) if err != nil { if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { return ErrWrongUsernameOrPassword{} } return err } return nil } // GetCurrentUserFromDB gets a user from jwt claims and returns the full user from the db. func GetCurrentUserFromDB(s *xorm.Session, c echo.Context) (user *User, err error) { u, err := GetCurrentUser(c) if err != nil { return nil, err } return GetUserByID(s, u.ID) } // GetCurrentUser returns the current user based on its jwt token func GetCurrentUser(c echo.Context) (user *User, err error) { jwtinf := c.Get("user").(*jwt.Token) claims := jwtinf.Claims.(jwt.MapClaims) return GetUserFromClaims(claims) } // GetUserFromClaims Returns a new user from jwt claims func GetUserFromClaims(claims jwt.MapClaims) (user *User, err error) { userID, ok := claims["id"].(float64) if !ok { return user, ErrCouldNotGetUserID{} } user = &User{ ID: int64(userID), Email: claims["email"].(string), Username: claims["username"].(string), Name: claims["name"].(string), } return } // UpdateUser updates a user func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) { // Check if it exists theUser, err := GetUserWithEmail(s, &User{ID: user.ID}) if err != nil { return &User{}, err } // Check if we have at least a username if user.Username == "" { user.Username = theUser.Username // Dont change the username if we dont have one } else { // Check if the new username already exists uu, err := GetUserByUsername(s, user.Username) if err != nil && !IsErrUserDoesNotExist(err) { return nil, err } if uu.ID != 0 && uu.ID != user.ID { return nil, &ErrUsernameExists{Username: user.Username, UserID: uu.ID} } } // Check if we have a name if user.Name == "" { user.Name = theUser.Name } // Check if the email is already used if user.Email == "" { user.Email = theUser.Email } else { uu, err := getUser(s, &User{ Email: user.Email, Issuer: user.Issuer, Subject: user.Subject, }, true) if err != nil && !IsErrUserDoesNotExist(err) { return nil, err } if uu.ID != 0 && uu.ID != user.ID { return nil, &ErrUserEmailExists{Email: user.Email, UserID: uu.ID} } } // Validate the avatar type if user.AvatarProvider != "" { if user.AvatarProvider != "default" && user.AvatarProvider != "gravatar" && user.AvatarProvider != "initials" && user.AvatarProvider != "upload" && user.AvatarProvider != "marble" { return updatedUser, &ErrInvalidAvatarProvider{AvatarProvider: user.AvatarProvider} } } // Check if we have a valid time zone if user.Timezone == "" { user.Timezone = config.GetTimeZone().String() } _, err = time.LoadLocation(user.Timezone) if err != nil { return } // Update it _, err = s. ID(user.ID). Cols( "username", "email", "avatar_provider", "avatar_file_id", "is_active", "name", "email_reminders_enabled", "discoverable_by_name", "discoverable_by_email", "overdue_tasks_reminders_enabled", "default_list_id", "week_start", "language", "timezone", "overdue_tasks_reminders_time", ). Update(user) if err != nil { return &User{}, err } // Get the newly updated user updatedUser, err = GetUserByID(s, user.ID) if err != nil { return &User{}, err } return updatedUser, err } // UpdateUserPassword updates the password of a user func UpdateUserPassword(s *xorm.Session, user *User, newPassword string) (err error) { if newPassword == "" { return ErrEmptyNewPassword{} } // Get all user details theUser, err := GetUserByID(s, user.ID) if err != nil { return err } // Hash the new password and set it hashed, err := HashPassword(newPassword) if err != nil { return err } theUser.Password = hashed // Update it _, err = s.ID(user.ID).Update(theUser) if err != nil { return err } return err } // SetStatus sets a users status in the database func (u *User) SetStatus(s *xorm.Session, status Status) (err error) { u.Status = status _, err = s. Where("id = ?", u.ID). Cols("status"). Update(u) return }