61d49c3a56
Instead of naeveily checking for all reminders due in the next minute, we now check all reminders in all time zones in the next minutes. This essentially means checking for reminders due in the next 14 or past 12 hours. We then check for each user who would receive a reminder from that result if it is actually due in their time zone. This should prevent issues where users would get the reminder in the time zone of their server, not in their own. Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1092 Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
546 lines
14 KiB
Go
546 lines
14 KiB
Go
// 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 (
|
|
"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"`
|
|
}
|
|
|
|
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:"-"`
|
|
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 returing 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 = ""
|
|
}
|
|
|
|
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{}
|
|
}
|
|
|
|
// 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 nil, 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",
|
|
).
|
|
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
|
|
}
|