api/pkg/user/user.go
konrad 61d49c3a56 feat: add time zone setting for reminders (#1092)
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: vikunja/api#1092
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
2022-01-16 11:05:56 +00:00

547 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
}