def sign_in_with_form_data(body, state): p = None _, website = state['_'], state['website'] if body.get('log-in.id'): request = state['request'] src_addr, src_country = request.source, request.country input_id = body['log-in.id'].strip() password = body.pop('log-in.password', None) id_type = None if input_id.find('@') > 0: id_type = 'email' elif input_id.startswith('~'): id_type = 'immutable' elif set(input_id).issubset(ASCII_ALLOWED_IN_USERNAME): id_type = 'username' if password and id_type: website.db.hit_rate_limit('log-in.password.ip-addr', str(src_addr), TooManyLogInAttempts) if id_type == 'immutable': p_id = Participant.check_id(input_id[1:]) else: p_id = Participant.get_id_for(id_type, input_id) try: p = Participant.authenticate_with_password(p_id, password) except AccountIsPasswordless: if id_type == 'email': state['log-in.email'] = input_id else: state['log-in.error'] = _( "The submitted password is incorrect.") return if not p: state['log-in.error'] = (_( "The submitted password is incorrect." ) if p_id is not None else _( "“{0}” is not a valid account ID.", input_id ) if id_type == 'immutable' else _( "No account has the username “{username}”.", username=input_id ) if id_type == 'username' else _( "No account has “{email_address}” as its primary email address.", email_address=input_id)) else: website.db.decrement_rate_limit('log-in.password.ip-addr', str(src_addr)) try: p.check_password(password, context='login') except Exception as e: website.tell_sentry(e) elif id_type == 'email': website.db.hit_rate_limit('log-in.email.ip-addr', str(src_addr), TooManyLogInAttempts) website.db.hit_rate_limit('log-in.email.ip-net', get_ip_net(src_addr), TooManyLogInAttempts) website.db.hit_rate_limit('log-in.email.country', src_country, TooManyLogInAttempts) email = input_id p = Participant.from_email(email) if p and p.kind == 'group': state['log-in.error'] = _( "{0} is linked to a team account. It's not possible to log in as a team.", email) elif p: if not p.get_email(email).verified: website.db.hit_rate_limit('log-in.email.not-verified', email, TooManyLoginEmails) website.db.hit_rate_limit('log-in.email', p.id, TooManyLoginEmails) email_row = p.get_email(email) p.send_email('login_link', email_row, link_validity=SESSION_TIMEOUT) state['log-in.email-sent-to'] = email raise LoginRequired else: state['log-in.error'] = _( "No account has “{email_address}” as its primary email address.", email_address=email) p = None else: state['log-in.error'] = _("\"{0}\" is not a valid email address.", input_id) return elif 'sign-in.email' in body: response = state['response'] # Check the submitted data kind = body.pop('sign-in.kind', 'individual') if kind not in ('individual', 'organization'): raise response.invalid_input(kind, 'sign-in.kind', 'body') email = body['sign-in.email'] if not email: raise response.error(400, 'email is required') email = normalize_and_check_email_address(email) currency = (body.get('sign-in.currency') or body.get('currency') or state.get('currency') or 'EUR') if currency not in CURRENCIES: raise response.invalid_input(currency, 'sign-in.currency', 'body') password = body.get('sign-in.password') if password: l = len(password) if l < PASSWORD_MIN_SIZE or l > PASSWORD_MAX_SIZE: raise BadPasswordSize username = body.get('sign-in.username') if username: username = username.strip() Participant.check_username(username) session_token = body.get('sign-in.token', '') if session_token: Participant.check_session_token(session_token) # Check for an existing account is_duplicate_request = website.db.hit_rate_limit( 'sign-up.email', email) is None for i in range(5): existing_account = website.db.one( """ SELECT p FROM emails e JOIN participants p ON p.id = e.participant WHERE lower(e.address) = lower(%s) AND ( e.verified IS TRUE OR e.added_time > (current_timestamp - interval '1 day') OR p.email IS NULL ) ORDER BY p.join_time DESC LIMIT 1 """, (email, )) if is_duplicate_request and not existing_account: # The other thread hasn't created the account yet. sleep(1) else: break if existing_account: session = website.db.one( """ SELECT id, secret, mtime FROM user_secrets WHERE participant = %s AND id = 1 AND mtime < (%s + interval '6 hours') AND mtime > (current_timestamp - interval '6 hours') """, (existing_account.id, existing_account.join_time)) if session and constant_time_compare(session_token, session.secret.split('.')[0]): p = existing_account p.authenticated = True p.sign_in(response.headers.cookie, session=session) return p else: raise EmailAlreadyTaken(email) username_taken = website.db.one( """ SELECT count(*) FROM participants p WHERE p.username = %s """, (username, )) if username_taken: raise UsernameAlreadyTaken(username) # Rate limit request = state['request'] src_addr, src_country = request.source, request.country website.db.hit_rate_limit('sign-up.ip-addr', str(src_addr), TooManySignUps) website.db.hit_rate_limit('sign-up.ip-net', get_ip_net(src_addr), TooManySignUps) website.db.hit_rate_limit('sign-up.country', src_country, TooManySignUps) website.db.hit_rate_limit('sign-up.ip-version', src_addr.version, TooManySignUps) # Okay, create the account with website.db.get_cursor() as c: request_data = { 'url': request.line.uri.decoded, 'headers': get_recordable_headers(request), } p = Participant.make_active(kind, currency, username, cursor=c, request_data=request_data) p.set_email_lang(state['locale'].language, cursor=c) p.add_email(email, cursor=c) if password: p.update_password(password) p.check_password(password, context='login') p.authenticated = True p.sign_in(response.headers.cookie, token=session_token, suffix='.in') website.logger.info(f"a new participant has joined: ~{p.id}") # We're done, we can clean up the body now body.pop('sign-in.email') body.pop('sign-in.currency', None) body.pop('sign-in.password', None) body.pop('sign-in.username', None) body.pop('sign-in.token', None) return p
def test_email_login(self): email = '*****@*****.**' alice = self.make_participant('alice', email=None) alice.add_email(email) alice.close() # Sanity checks email_row = alice.get_email(email) assert email_row.verified is None assert alice.email is None # Initiate email log-in data = {'log-in.id': email.upper()} r = self.client.POST('/', data, raise_immediately=False) session = self.db.one( "SELECT * FROM user_secrets WHERE participant = %s", (alice.id, )) assert session.secret not in r.headers.raw.decode('ascii') assert session.secret not in r.body.decode('utf8') # Check the email message Participant.dequeue_emails() last_email = self.get_last_email() assert last_email and last_email['subject'] == 'Log in to Liberapay' qs = 'log-in.id=%i&log-in.key=%i&log-in.token=%s&email.id=%s&email.nonce=%s' % ( alice.id, session.id, session.secret, email_row.id, email_row.nonce) assert qs in last_email['text'] # Attempt to use the URL in a new browser session (no anti-CSRF cookie yet) r = self.client.GxT('/alice/?foo=bar&' + qs, csrf_token=None) assert r.code == 200 refresh_qs = '?foo=bar&' + qs + '&cookie_sent=true' assert r.headers[b'Refresh'] == b"0;url=" + refresh_qs.encode() assert CSRF_TOKEN in r.headers.cookie # Follow the redirect, still without cookies r = self.client.GxT('/alice/' + refresh_qs, csrf_token=None) assert r.code == 403, r.text assert "Please make sure your browser is configured to allow cookies" in r.text # Log in csrf_token = '_ThisIsAThirtyTwoBytesLongToken_' confirmation_token = b64encode_s( blake2b( session.secret.encode(), key=csrf_token.encode(), digest_size=48, ).digest()) r = self.client.GxT('/alice/' + refresh_qs, csrf_token=csrf_token) assert r.code == 200 assert SESSION not in r.headers.cookie assert confirmation_token in r.text r = self.client.GxT( '/alice/' + refresh_qs + '&log-in.confirmation=' + confirmation_token, csrf_token=csrf_token, ) assert r.code == 302 assert SESSION in r.headers.cookie assert r.headers[b'Location'].startswith( b'http://localhost/alice/?foo=bar&success=') # ↑ checks that original path and query are preserved old_secret = self.db.one( """ SELECT secret FROM user_secrets WHERE participant = %s AND id = %s AND secret = %s """, (alice.id, session.id, session.secret)) assert old_secret is None # ↑ this means that the link is only valid once # Check that the email address is now verified email_row = alice.get_email(email) assert email_row.verified alice = alice.refetch() assert alice.email == email # Check what happens if the user clicks the login link a second time cookies = r.headers.cookie r = self.client.GxT('/alice/?foo=bar&' + qs, cookies=cookies) assert r.code == 400 assert " already logged in," in r.text, r.text # Check that we can change our password password = '******' r = self.client.POST( '/alice/settings/edit', {'new-password': password}, cookies=cookies, raise_immediately=False, ) assert r.code == 302 alice2 = Participant.authenticate_with_password(alice.id, password) assert alice2 and alice2 == alice