Files
tryton/modules/web_user/user.py
T

491 lines
16 KiB
Python
Raw Normal View History

2016-06-27 19:18:17 +02:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
2018-08-25 13:06:05 +02:00
import logging
2016-06-27 19:18:17 +02:00
import random
import time
2018-06-26 19:11:58 +02:00
import urllib.parse
2021-12-18 16:34:37 +01:00
from secrets import token_hex
2016-06-27 19:18:17 +02:00
2024-07-27 18:36:14 +02:00
from sql import Literal, Null
2016-06-27 19:18:17 +02:00
from sql.conditionals import Coalesce
from sql.functions import CurrentTimestamp
from sql.operators import Equal
2016-06-27 19:18:17 +02:00
2025-10-07 08:19:57 +02:00
import trytond.config as config
2018-02-14 19:25:33 +01:00
from trytond.exceptions import RateLimitException
2023-04-29 19:07:13 +02:00
from trytond.i18n import gettext
2018-03-26 14:15:42 +02:00
from trytond.model import (
DeactivableMixin, Exclude, Index, ModelSQL, ModelView, Unique,
avatar_mixin, fields)
2016-06-27 19:18:17 +02:00
from trytond.pool import Pool
from trytond.pyson import Eval
2017-09-27 18:51:10 +02:00
from trytond.report import Report, get_email
2024-12-01 14:25:26 +01:00
from trytond.res.user import PASSWORD_HASH, LoginAttempt
2024-02-21 21:38:53 +01:00
from trytond.sendmail import send_message_transactional
2023-04-29 19:07:13 +02:00
from trytond.tools.email_ import (
2023-04-29 19:03:42 +02:00
EmailNotValidError, normalize_email, set_from_header, validate_email)
from trytond.transaction import Transaction, without_check_access
2016-06-27 19:18:17 +02:00
2023-04-29 19:07:13 +02:00
from .exceptions import UserValidationError
2018-08-25 13:06:05 +02:00
logger = logging.getLogger(__name__)
2016-06-27 19:18:17 +02:00
def _send_email(from_, users, email_func):
from_cfg = config.get('email', 'from')
2016-06-27 19:18:17 +02:00
for user in users:
msg, title = email_func(user)
2021-09-26 23:11:21 +02:00
set_from_header(msg, from_cfg, from_ or from_cfg)
2016-06-27 19:18:17 +02:00
msg['To'] = user.email
2024-01-25 16:04:05 +01:00
msg['Subject'] = title
2024-02-21 21:38:53 +01:00
send_message_transactional(msg)
2016-06-27 19:18:17 +02:00
def _add_params(url, **params):
2018-06-26 19:11:58 +02:00
parts = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qsl(parts.query)
2016-06-27 19:18:17 +02:00
for key, value in sorted(params.items()):
query.append((key, value))
parts = list(parts)
2018-06-26 19:11:58 +02:00
parts[3] = urllib.parse.urlencode(query)
return urllib.parse.urlunsplit(parts)
2016-06-27 19:18:17 +02:00
2017-06-13 13:59:13 +02:00
def _extract_params(url):
2018-06-26 19:11:58 +02:00
return urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query)
2017-06-13 13:59:13 +02:00
2021-03-28 19:20:37 +02:00
class User(avatar_mixin(100), DeactivableMixin, ModelSQL, ModelView):
2016-06-27 19:18:17 +02:00
__name__ = 'web.user'
_rec_name = 'email'
email = fields.Char(
2024-08-22 09:52:06 +02:00
"Email",
2018-03-19 16:20:15 +01:00
states={
'required': Eval('active', True),
})
2024-08-22 09:52:06 +02:00
email_valid = fields.Boolean('Email Valid')
email_token = fields.Char("Email Token", strip=False)
2016-06-27 19:18:17 +02:00
password_hash = fields.Char('Password Hash')
password = fields.Function(
fields.Char('Password'), 'get_password', setter='set_password')
reset_password_token = fields.Char("Reset Password Token", strip=False)
2016-06-27 19:18:17 +02:00
reset_password_token_expire = fields.Timestamp(
'Reset Password Token Expire')
party = fields.Many2One('party.party', 'Party', ondelete='RESTRICT')
2020-10-12 18:11:20 +02:00
secondary_parties = fields.Many2Many(
'web.user-party.party.secondary', 'user', 'party', "Secondary Parties")
2016-06-27 19:18:17 +02:00
@classmethod
def __setup__(cls):
2025-04-04 00:45:18 +02:00
super().__setup__()
2016-06-27 19:18:17 +02:00
table = cls.__table__()
cls._sql_constraints += [
('email_exclude',
Exclude(table, (table.email, Equal),
2020-03-01 12:33:54 +01:00
where=table.active == Literal(True)),
2018-12-29 14:20:30 +01:00
'web_user.msg_user_email_unique'),
2016-06-27 19:18:17 +02:00
]
cls._sql_indexes.update({
2024-07-27 18:36:14 +02:00
Index(
table, (Coalesce(table.email, ''),
Index.Equality(cardinality='high'))),
2024-07-27 18:36:14 +02:00
Index(
table,
(Coalesce(table.email_token, ''),
Index.Equality(cardinality='high')),
2024-07-27 18:36:14 +02:00
where=table.email_token != Null),
})
2016-06-27 19:18:17 +02:00
cls._buttons.update({
'validate_email': {
'readonly': Eval('email_valid', False),
2018-02-19 11:42:30 +01:00
'depends': ['email_valid'],
2016-06-27 19:18:17 +02:00
},
'reset_password': {
'readonly': ~Eval('email_valid', False),
2018-02-19 11:42:30 +01:00
'depends': ['email_valid'],
2016-06-27 19:18:17 +02:00
},
})
@classmethod
def default_email_valid(cls):
return False
def get_password(self, name):
return 'x' * 10
@classmethod
def set_password(cls, users, name, value):
2017-04-03 17:05:56 +02:00
pool = Pool()
User = pool.get('res.user')
2016-06-27 19:18:17 +02:00
if value == 'x' * 10:
return
2017-04-03 17:05:56 +02:00
if Transaction().user and value:
User.validate_password(value, users)
2016-06-27 19:18:17 +02:00
to_write = []
for user in users:
to_write.extend([[user], {
'password_hash': cls.hash_password(value),
}])
cls.write(*to_write)
2022-04-11 22:13:53 +02:00
@fields.depends('party', 'email')
def on_change_party(self):
if not self.email and self.party:
self.email = self.party.email
@classmethod
def copy(cls, users, default=None):
default = default.copy() if default is not None else {}
default['password_hash'] = None
default['reset_password_token'] = None
return super().copy(users, default=default)
2017-06-24 10:58:54 +02:00
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if values.get('email'):
values['email'] = normalize_email(values['email']).lower()
return values
2017-06-24 10:58:54 +02:00
2023-04-29 19:07:13 +02:00
@classmethod
def validate_fields(cls, users, fields_names):
super().validate_fields(users, fields_names)
cls.check_valid_email(users, fields_names)
@classmethod
def check_valid_email(cls, users, fields_names=None):
if fields_names and 'email' not in fields_names:
return
for user in users:
if user.email:
try:
validate_email(user.email)
except EmailNotValidError as e:
raise UserValidationError(gettext(
'web_user.msg_user_email_invalid',
2023-04-29 19:07:13 +02:00
user=user.rec_name,
email=user.email),
str(e)) from e
2016-06-27 19:18:17 +02:00
@classmethod
def authenticate(cls, email, password):
pool = Pool()
Attempt = pool.get('web.user.authenticate.attempt')
2017-06-24 10:58:54 +02:00
email = email.lower()
2016-06-27 19:18:17 +02:00
2018-02-14 19:25:33 +01:00
count_ip = Attempt.count_ip()
if count_ip > config.getint(
'session', 'max_attempt_ip_network', default=300):
# Do not add attempt as the goal is to prevent flooding
raise RateLimitException()
count = Attempt.count(email)
if count > config.getint('session', 'max_attempt', default=5):
Attempt.add(email)
raise RateLimitException()
2016-06-27 19:18:17 +02:00
# Prevent brute force attack
2018-02-14 19:25:33 +01:00
Transaction().atexit(time.sleep, 2 ** count - 1)
2016-06-27 19:18:17 +02:00
users = cls.search([('email', '=', email)])
if users:
user, = users
2018-08-25 13:06:05 +02:00
valid, new_hash = cls.check_password(password, user.password_hash)
if valid:
if new_hash:
logger.info("Update password hash for %s", user.id)
with Transaction().new_transaction():
with without_check_access():
2018-08-25 13:06:05 +02:00
cls.write([cls(user.id)], {
'password_hash': new_hash,
})
2016-06-27 19:18:17 +02:00
Attempt.remove(email)
return user
Attempt.add(email)
@classmethod
def hash_password(cls, password):
'''Hash given password in the form
<hash_method>$<password>$<salt>...'''
if not password:
2024-12-01 14:31:18 +01:00
return None
2024-12-01 14:25:26 +01:00
return PASSWORD_HASH.hash(password)
2016-06-27 19:18:17 +02:00
@classmethod
def check_password(cls, password, hash_):
2024-12-01 14:31:18 +01:00
return PASSWORD_HASH.verify_and_update(password, hash_)
2016-06-27 19:18:17 +02:00
def new_session(self):
pool = Pool()
Session = pool.get('web.user.session')
return Session.add(self)
@classmethod
def get_user(cls, session):
pool = Pool()
Session = pool.get('web.user.session')
return Session.get_user(session)
@classmethod
@ModelView.button
def validate_email(cls, users, from_=None):
for user in users:
user.set_email_token()
cls.save(users)
_send_email(from_, users, cls.get_email_validation)
2017-09-03 11:19:48 +02:00
def set_email_token(self, nbytes=None):
self.email_token = token_hex(nbytes)
2016-06-27 19:18:17 +02:00
def get_email_validation(self):
2017-09-27 18:51:10 +02:00
return get_email(
'web.user.email_validation', self, self.languages)
2016-06-27 19:18:17 +02:00
def get_email_validation_url(self, url=None):
if url is None:
url = config.get('web', 'email_validation_url')
2026-01-19 12:30:41 +01:00
if url is not None:
return _add_params(url, token=self.email_token)
else:
return ''
2016-06-27 19:18:17 +02:00
@classmethod
def validate_email_url(cls, url):
2018-06-26 19:11:58 +02:00
parts = urllib.parse.urlsplit(url)
2016-06-27 19:18:17 +02:00
tokens = filter(
2018-06-26 19:11:58 +02:00
None, urllib.parse.parse_qs(parts.query).get('token', [None]))
return cls.validate_email_token(list(tokens))
2016-06-27 19:18:17 +02:00
@classmethod
def validate_email_token(cls, tokens):
users = cls.search([
('email_token', 'in', tokens),
])
cls.write(users, {
'email_valid': True,
'email_token': None,
})
2021-03-18 21:28:50 +01:00
return users
2016-06-27 19:18:17 +02:00
@classmethod
@ModelView.button
def reset_password(cls, users, from_=None):
now = datetime.datetime.now()
# Prevent abusive reset
def reset(user):
return not (user.reset_password_token_expire
and user.reset_password_token_expire > now)
2018-06-26 19:11:58 +02:00
users = list(filter(reset, users))
2016-06-27 19:18:17 +02:00
for user in users:
user.set_reset_password_token()
cls.save(users)
_send_email(from_, users, cls.get_email_reset_password)
2017-09-03 11:19:48 +02:00
def set_reset_password_token(self, nbytes=None):
self.reset_password_token = token_hex(nbytes)
2016-06-27 19:18:17 +02:00
self.reset_password_token_expire = (
datetime.datetime.now() + datetime.timedelta(
seconds=config.getint(
'session', 'web_timeout_reset', default=24 * 60 * 60)))
def clear_reset_password_token(self):
self.reset_password_token = None
self.reset_password_token_expire = None
def get_email_reset_password(self):
2017-09-27 18:51:10 +02:00
return get_email(
'web.user.email_reset_password', self, self.languages)
2016-06-27 19:18:17 +02:00
def get_email_reset_password_url(self, url=None):
if url is None:
url = config.get('web', 'reset_password_url')
2026-01-19 12:30:41 +01:00
if url is not None:
return _add_params(
url, token=self.reset_password_token, email=self.email)
else:
return ''
2016-06-27 19:18:17 +02:00
@classmethod
def set_password_url(cls, url, password):
2018-06-26 19:11:58 +02:00
parts = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qs(parts.query)
2016-06-27 19:18:17 +02:00
email = query.get('email', [None])[0]
token = query.get('token', [None])[0]
return cls.set_password_token(email, token, password)
@classmethod
def set_password_token(cls, email, token, password):
pool = Pool()
Attempt = pool.get('web.user.authenticate.attempt')
2017-06-24 10:58:54 +02:00
email = email.lower()
2016-06-27 19:18:17 +02:00
# Prevent brute force attack
2020-01-21 00:36:34 +01:00
Transaction().atexit(
time.sleep, random.randint(0, 2 ** Attempt.count(email) - 1))
2016-06-27 19:18:17 +02:00
users = cls.search([
('email', '=', email),
])
if users:
user, = users
if user.reset_password_token == token:
now = datetime.datetime.now()
expire = user.reset_password_token_expire
user.clear_reset_password_token()
if expire > now:
user.password = password
user.save()
Attempt.remove(email)
return True
Attempt.add(email)
return False
2017-09-27 18:51:10 +02:00
@property
def languages(self):
pool = Pool()
Language = pool.get('ir.lang')
if self.party and self.party.lang:
languages = [self.party.lang]
else:
languages = Language.search([
('code', '=', Transaction().language),
])
return languages
2016-06-27 19:18:17 +02:00
2020-10-12 18:11:20 +02:00
class User_PartySecondary(ModelSQL):
__name__ = 'web.user-party.party.secondary'
user = fields.Many2One(
'web.user', "User", required=True, ondelete='CASCADE')
2020-10-12 18:11:20 +02:00
party = fields.Many2One(
'party.party', "Party", required=True, ondelete='CASCADE')
2016-06-27 19:18:17 +02:00
class UserAuthenticateAttempt(LoginAttempt):
__name__ = 'web.user.authenticate.attempt'
2016-08-27 01:07:37 +02:00
_table = None # Needed to reset LoginAttempt._table
2016-06-27 19:18:17 +02:00
class UserSession(ModelSQL):
__name__ = 'web.user.session'
_rec_name = 'key'
key = fields.Char("Key", required=True, strip=False)
2016-06-27 19:18:17 +02:00
user = fields.Many2One(
'web.user', "User", required=True, ondelete='CASCADE')
2016-06-27 19:18:17 +02:00
@classmethod
def __setup__(cls):
2025-04-04 00:45:18 +02:00
super().__setup__()
2016-06-27 19:18:17 +02:00
table = cls.__table__()
cls.__rpc__ = {}
cls._sql_constraints += [
2018-12-29 14:20:30 +01:00
('key_unique', Unique(table, table.key),
'web_user.msg_user_session_key_unique'),
2016-06-27 19:18:17 +02:00
]
cls._sql_indexes.update({
Index(
table,
(Coalesce(table.write_date, table.create_date),
Index.Range())),
2024-07-27 18:36:14 +02:00
Index(table, (table.key, Index.Equality(cardinality='high'))),
})
2016-06-27 19:18:17 +02:00
@classmethod
2017-09-03 11:19:48 +02:00
def default_key(cls, nbytes=None):
return token_hex(nbytes)
2016-06-27 19:18:17 +02:00
@classmethod
def add(cls, user):
cursor = Transaction().connection.cursor()
table = cls.__table__()
cursor.execute(*table.delete(
where=(
Coalesce(table.write_date, table.create_date)
< CurrentTimestamp() - cls.timeout())))
2016-06-27 19:18:17 +02:00
session = cls(user=user)
session.save()
return session.key
2018-05-29 10:36:49 +02:00
@classmethod
def remove(cls, key):
sessions = cls.search([
('key', '=', key),
])
cls.delete(sessions)
2016-06-27 19:18:17 +02:00
@classmethod
def get_user(cls, session):
transaction = Transaction()
2016-06-27 19:18:17 +02:00
sessions = cls.search([
('key', '=', session),
])
if not sessions:
return
session, = sessions
if not session.expired:
return session.user
elif not transaction.readonly:
2016-06-27 19:18:17 +02:00
cls.delete([session])
@classmethod
def timeout(cls):
return datetime.timedelta(seconds=config.getint(
'session', 'web_timeout', default=30 * 24 * 60 * 60))
@property
def expired(self):
now = datetime.datetime.now()
return abs(self.last_modified_at - now) > self.timeout()
2016-06-27 19:18:17 +02:00
2018-05-29 10:36:49 +02:00
@classmethod
def reset(cls, session):
sessions = cls.search([
('key', '=', session),
])
cls.write(sessions, {})
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if mode == 'create':
# Ensure to get a different key for each record
# default methods are called only once
values.setdefault('key', cls.default_key())
return values
2016-06-27 19:18:17 +02:00
class EmailValidation(Report):
__name__ = 'web.user.email_validation'
2017-06-13 13:59:13 +02:00
@classmethod
2021-02-21 19:19:26 +01:00
def get_context(cls, records, header, data):
context = super().get_context(records, header, data)
2017-06-13 13:59:13 +02:00
context['extract_params'] = _extract_params
return context
2016-06-27 19:18:17 +02:00
class EmailResetPassword(Report):
__name__ = 'web.user.email_reset_password'
2017-06-13 13:59:13 +02:00
@classmethod
2021-02-21 19:19:26 +01:00
def get_context(cls, records, header, data):
context = super().get_context(records, header, data)
2017-06-13 13:59:13 +02:00
context['extract_params'] = _extract_params
2022-05-09 23:43:12 +02:00
expire_delay = (
records[0].reset_password_token_expire - datetime.datetime.now())
# Use a precision of minutes
expire_delay = datetime.timedelta(
days=expire_delay.days,
minutes=round(expire_delay.seconds / 60))
context['expire_delay'] = expire_delay
2017-06-13 13:59:13 +02:00
return context