import time import uuid import logging from datetime import timedelta from django.db import models from django.core.mail import send_mail from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.contrib.auth.hashers import is_password_usable from django.contrib.auth.validators import UnicodeUsernameValidator from django.utils import timezone from django.conf import settings from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from pingcap_sso.managers.user import UserManager from pingcap_sso.utils.requests import get_client_agent, get_client_ip from pingcap_sso.validators.phone import PhoneNumberValidator from pingcap_sso.validators.phone import normalize_phone_number from pingcap_sso.models.security_code import SecurityCode from pingcap_sso.models.tug_members import TUGMember from pingcap_sso.models.tug_member_applications import TUGMemberApplication logger = logging.getLogger(__name__) def generate_openid(): return str(uuid.uuid1()) def generate_avatar_url(): return 'https://asktug.com/letter_avatar_proxy/v4/letter/t/4e50e8/90.png' def generate_fake_email(): return '{}@fake.email'.format(str(uuid.uuid1())) # Notice: If user info field changed, please update oidc config: # * `.well-known/openid-configuration` # * userinfo endpoint # * id_token class User(AbstractBaseUser, PermissionsMixin): """ Customise User Model. settings.py: AUTH_USER_MODEL = 'pingcap_sso.User' """ GENDER_MALE = 0 GENDER_FEMALE = 1 GENDER_CHOICES = ( (GENDER_MALE, _('male')), (GENDER_FEMALE, _('female')) ) COMPANY_UNVERIFIED = 0 COMPANY_VERIFIED_PENDING = 1 COMPANY_VERIFIED_PASS = 2 COMPANY_VERIFIED_STATUS_CHOICES = ( (COMPANY_UNVERIFIED, _('unverified')), (COMPANY_VERIFIED_PENDING, _('pending')), (COMPANY_VERIFIED_PASS, _('pass')) ) openid = models.CharField( 'openid', max_length=64, unique=True, default=generate_openid ) username = models.CharField( 'username', max_length=60, unique=True, validators=(UnicodeUsernameValidator(),), help_text='Required. 150 characters or fewer.' 'Letters, digits and @/./+/-/_ only.', error_messages={ 'unique': 'A user with that username already exists.', }, ) username_last_modified_at = models.DateTimeField( 'username last modified at', default=None, blank=True, null=True) email = models.EmailField( 'email', null=True, blank=True, unique=True, error_messages={ 'unique': 'A user with that email already exists.', }, ) fake_email = models.EmailField( 'fake email', null=True, blank=True, unique=True, default=generate_fake_email ) email_verified = models.BooleanField( 'email verified', default=False ) phone = models.CharField( 'phone number', max_length=32, unique=True, null=True, blank=True, validators=(PhoneNumberValidator(),), help_text='Phone number is required.', error_messages={ 'unique': 'A user with that phone number already exists' } ) name = models.CharField( 'real name', max_length=128, blank=True, null=True ) avatar_url = models.URLField(default=generate_avatar_url) is_staff = models.BooleanField( 'staff status', default=False, help_text='Designates whether the user can log into this admin site.', ) is_active = models.BooleanField( 'active', default=True, help_text='Designates whether this user should be treated as active. ' 'Unselect this instead of deleting accounts.', ) ip_address = models.GenericIPAddressField(null=True, blank=True) registration_ip_address = models.GenericIPAddressField(null=True, blank=True) trust_level = models.IntegerField(default=1) date_joined = models.DateTimeField('date joined', default=timezone.now) bio = models.CharField(max_length=255, default=None, blank=True, null=True) gender = models.SmallIntegerField( default=None, blank=True, null=True, choices=GENDER_CHOICES) date_of_birth = models.DateField( default=None, blank=True, null=True) address = models.CharField( max_length=128, default=None, blank=True, null=True) company_name = models.CharField( max_length=100, default=None, blank=True, null=True) job_title_code = models.CharField(max_length=64, blank=True, null=True) job_title_text = models.CharField(max_length=64, blank=True, null=True) company_verified_status = models.SmallIntegerField( default=COMPANY_UNVERIFIED, choices=COMPANY_VERIFIED_STATUS_CHOICES) is_contributor = models.BooleanField(default=False) pass_pcta = models.BooleanField(default=False) pass_pctp = models.BooleanField(default=False) pass_pcsd = models.BooleanField(default=False) pass_praczh = models.BooleanField(default=False) signup_email = models.EmailField(blank=True, null=True) signup_channel = models.CharField(max_length=64, blank=True, null=True) signup_utm_attribution = models.JSONField(default=dict) signup_source_url = models.CharField(max_length=512, blank=True, null=True) objects = UserManager() EMAIL_FIELD = 'email' USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email', 'phone'] class Meta: db_table = 'auth_user' verbose_name = 'user' verbose_name_plural = 'users' permissions = ( ('view_user_list', 'Can view user list'), ('view_user_profile', 'Can view user profile'), ('view_user_contact_info', 'Can view user contact info such as phone and email'), ('view_user_portrait', 'Can view and search user portrait'), ('view_company_analytics', 'Can view company analytics'), ('manage_staff', 'Can manage staffs'), ('sso_get_session_detail', 'SSO, Get user data by session id'), ('view_site_analytics', 'Can view site analytics dashboard and reports'), ('view_user_analytics', 'Can view user analytics'), ('view_metabase', 'Can view metabase dashboard'), ('view_user_by_search_usernames', 'Can view user by search usernames'), ('view_user_by_search_companys', 'Can view user by search company names'), ('manage_blog', 'Can manage blog tidb.net/blog'), ) def clean(self): super(User, self).clean() self.email = self.__class__.objects.normalize_email(self.email) self.phone = normalize_phone_number(self.phone) def get_full_name(self): return self.name def get_short_name(self): """ Returns the name for the user. """ return self.name def email_user(self, subject, message, from_email=None, **kwargs): """ Sends an email to this User. """ send_mail(subject, message, from_email, [self.email], **kwargs) def check_sms_code(self, code): return SecurityCode.check_code( self.phone, code, channel=SecurityCode.DELIVER_BY_PHONE) def check_email_code(self, code): return SecurityCode.check_code( self.email, code, channel=SecurityCode.DELIVER_BY_EMAIL) @property def has_password(self): return is_password_usable(self.password) @property def can_update_username(self): if not self.username_last_modified_at: return True if self.username_last_modified_at < (timezone.now() - timedelta(days=30)): return True return False @property def is_tug_member(self): return TUGMember.objects.filter(user=self).exists() @property def has_tug_memeber_pending_application(self): return TUGMemberApplication.objects.filter( user=self, status=TUGMemberApplication.STATUS_PENDING).exists() def get_asktug_user(self, log_error_if_not_exists=True): from pingcap_sso.discourse.models import DiscourseSingleSignOnRecord try: asktug_sso_record = DiscourseSingleSignOnRecord.objects.get(external_id=self.openid) except DiscourseSingleSignOnRecord.DoesNotExist: if log_error_if_not_exists: logger.error(f'SSO user does not found in asktug by external_id: { self.openid }') return None return asktug_sso_record.user @property def is_dangerous(self): """ Reutrn `False` if user login falied too many times recently. """ start_time = timezone.now() - timezone.timedelta(hours=6) failed_times = LoginLog.objects.filter( user=self, login_time__gte=start_time, login_status=LoginLog.LOGIN_FAILED ).count() if failed_times >= 3: return True sms_code_nums = SecurityCode.objects.filter( deliver_address=self.phone, updated_at__gte=start_time ).count() if sms_code_nums >= 10: return True return False def create_login_log(self, request, login_status): client_ip = get_client_ip(request) client_agent = get_client_agent(request) LoginLog.objects.create( user=self, user_info=self.phone or self.email or self.username, ip=client_ip, ua=client_agent, login_status=login_status, request_path=request.build_absolute_uri(None), ) @classmethod def generate_random_username(cls): # Try 100 times to generate unique username for i in range(100): username = 'TiDBer_' + get_random_string(8) if not cls.objects.filter(username=username): return username @classmethod def generate_new_password(cls): return get_random_string(15) def generate_reset_password_token(self, request): request.session['reset_password_user_id'] = self.id request.session['reset_password_expire_at'] = time.time() + 60 * 10 @classmethod def get_reset_password_user(cls, request): user = None if time.time() < request.session.get('reset_password_expire_at', 0): try: user = cls.objects.get( pk=request.session.get('reset_password_user_id', 0)) except cls.DoesNotExist: pass return user @classmethod def clean_reset_password_token(cls, request): request.session['reset_password_user'] = None request.session['reset_password_expire_at'] = 0 class LoginLog(models.Model): """ Record user's login history. """ LOGIN_SUCCESS = 0 LOGIN_FAILED = 1 LOGIN_CHOICES = ( (LOGIN_SUCCESS, 'Success'), (LOGIN_FAILED, 'Failed') ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True ) user_info = models.CharField( 'user info', max_length=255, help_text='usually we only record the email or phone' ) ip = models.GenericIPAddressField( 'ip', null=True, blank=True, ) ua = models.CharField( 'user agent', max_length=256 ) login_status = models.SmallIntegerField( 'results', default=LOGIN_SUCCESS, choices=LOGIN_CHOICES, ) login_time = models.DateTimeField( 'login time', auto_now_add=True, editable=False, ) request_path = models.CharField('request full path', max_length=256) class Meta: db_table = 'auth_login_log' def save(self, *args, **kwargs): self.ua = self.ua[:250] self.request_path = self.request_path[:250] super(LoginLog, self).save(*args, **kwargs)