Source code for login

"""Library to log the bot in to a wiki account."""
#
# (C) Pywikibot team, 2003-2024
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations

import codecs
import datetime
import os
import re
import webbrowser
from enum import IntEnum
from typing import Any
from warnings import warn

import pywikibot
from pywikibot import __url__, config
from pywikibot.comms import http
from pywikibot.exceptions import APIError, NoUsernameError
from pywikibot.tools import deprecated, file_mode_checker, normalize_username


try:
    import mwoauth
except ImportError as e:
    mwoauth = e


class _PasswordFileWarning(UserWarning):

    """The format of password file is incorrect."""


# On some wikis you are only allowed to run a bot if there is a link to
# the bot's user page in a specific list.
# If bots are listed in a template, the templates name must be given as
# second parameter, otherwise it must be None
botList = {
    'wikipedia': {
        'simple': ['Wikipedia:Bots', '/links']
    },
}


[docs] class LoginStatus(IntEnum): """ Enum for Login statuses. >>> LoginStatus.NOT_ATTEMPTED LoginStatus(-3) >>> LoginStatus.IN_PROGRESS.value -2 >>> LoginStatus.NOT_LOGGED_IN.name 'NOT_LOGGED_IN' >>> int(LoginStatus.AS_USER) 0 >>> LoginStatus(-3).name 'NOT_ATTEMPTED' >>> LoginStatus(0).name 'AS_USER' """ NOT_ATTEMPTED = -3 IN_PROGRESS = -2 NOT_LOGGED_IN = -1 AS_USER = 0 def __repr__(self) -> str: """Return internal representation.""" return f'LoginStatus({self})'
[docs] class LoginManager: """Site login manager.""" def __init__(self, password: str | None = None, site: pywikibot.site.BaseSite | None = None, user: str | None = None) -> None: """ Initializer. All parameters default to defaults in user-config. :param site: Site object to log into :param user: username to use. If user is None, the username is loaded from config.usernames. :param password: password to use :raises pywikibot.exceptions.NoUsernameError: No username is configured for the requested site. """ site = self.site = site or pywikibot.Site() if not user: config_names = config.usernames code_to_usr = config_names[site.family.name] or config_names['*'] try: user = code_to_usr.get(site.code) or code_to_usr['*'] except KeyError: raise NoUsernameError( 'ERROR: ' 'username for {site.family.name}:{site.code} is undefined.' '\nIf you have a username for that site, please add a ' 'line to user config file (user-config.py) as follows:\n' "usernames['{site.family.name}']['{site.code}'] = " "'myUsername'".format(site=site)) self.password = password self.login_name = self.username = user if getattr(config, 'password_file', ''): self.readPassword()
[docs] def check_user_exists(self) -> None: """ Check that the username exists on the site. .. seealso:: :api:`Users` :raises pywikibot.exceptions.NoUsernameError: Username doesn't exist in user list. """ # convert any Special:BotPassword usernames to main account equivalent main_username = self.username if '@' in self.username: warn( 'When using BotPasswords it is recommended that you store ' 'your login credentials in a password_file instead. See ' '{}/BotPasswords for instructions and more information.' .format(__url__)) main_username = self.username.partition('@')[0] try: data = self.site.allusers(start=main_username, total=1) user = next(data, {'name': None}) except APIError as e: if e.code == 'readapidenied': pywikibot.warning("Could not check user '{}' exists on {}" .format(main_username, self.site)) return raise if user['name'] != main_username: # Report the same error as server error code NotExists raise NoUsernameError("Username '{}' does not exist on {}" .format(main_username, self.site))
[docs] def botAllowed(self) -> bool: """ Check whether the bot is listed on a specific page. This allows bots to comply with the policy on the respective wiki. """ code, fam = self.site.code, self.site.family.name if code in botList.get(fam, []): botlist_pagetitle, bot_template_title = botList[fam][code] botlist_page = pywikibot.Page(self.site, botlist_pagetitle) if bot_template_title: for template, params in botlist_page.templatesWithParams(): if (template.title() == bot_template_title and params[0] == self.username): return True else: for linked_page in botlist_page.linkedPages(): if linked_page.title(with_ns=False) == self.username: return True return False # No bot policies on other sites return True
[docs] def login_to_site(self) -> None: """Login to the site.""" # This is overridden in ClientLoginManager raise NotImplementedError
[docs] def storecookiedata(self) -> None: """Store cookie data.""" http.cookie_jar.save(ignore_discard=True)
[docs] def readPassword(self) -> None: """Read passwords from a file. .. warning:: **Do not forget to remove read access for other users!** Use chmod 600 for password-file. All lines below should be valid Python tuples in the form ``(code, family, username, password)``, ``(family, username, password)`` or ``(username, password)`` to set a default password for an username. The last matching entry will be used, so default usernames should occur above specific usernames. .. note:: For BotPasswords the password should be given as a :class:`BotPassword` object. The file must be either encoded in ASCII or UTF-8. **Example**:: ('my_username', 'my_default_password') ('wikipedia', 'my_wikipedia_user', 'my_wikipedia_pass') ('en', 'wikipedia', 'my_en_wikipedia_user', 'my_en_wikipedia_pass') ('my_username', BotPassword('my_suffix', 'my_password')) """ # Set path to password file relative to the user_config # but fall back on absolute path for backwards compatibility assert config.base_dir is not None and config.password_file is not None password_file = os.path.join(config.base_dir, config.password_file) if not os.path.isfile(password_file): password_file = config.password_file # We fix password file permission first. file_mode_checker(password_file, mode=config.private_files_permission) with codecs.open(password_file, encoding='utf-8') as f: lines = f.readlines() line_nr = len(lines) + 1 for line in reversed(lines): line_nr -= 1 if not line.strip() or line.startswith('#'): continue try: entry = eval(line) except SyntaxError: entry = None if not isinstance(entry, tuple): warn(f'Invalid tuple in line {line_nr}', _PasswordFileWarning) continue if not 2 <= len(entry) <= 4: warn(f'The length of tuple in line {line_nr} should be 2 to 4 ' f'({entry} given)', _PasswordFileWarning) continue code, family, username, password = ( self.site.code, self.site.family.name)[:4 - len(entry)] + entry if (normalize_username(username) == self.username and family == self.site.family.name and code == self.site.code): if isinstance(password, str): self.password = password break if isinstance(password, BotPassword): self.password = password.password self.login_name = password.login_name(self.username) break warn('Invalid password format', _PasswordFileWarning)
_api_error = { 'NotExists': 'does not exist', 'Illegal': 'is invalid', 'readapidenied': 'does not have read permissions', 'Failed': 'does not have read permissions', 'FAIL': 'does not have read permissions', }
[docs] def login(self, retry: bool = False, autocreate: bool = False) -> bool: """ Attempt to log into the server. .. seealso:: :api:`Login` :param retry: infinitely retry if the API returns an unknown error :param autocreate: if true, allow auto-creation of the account using unified login :raises pywikibot.exceptions.NoUsernameError: Username is not recognised by the site. """ if not self.password: # First check that the username exists, # to avoid asking for a password that will not work. if not autocreate: self.check_user_exists() # As we don't want the password to appear on the screen, we set # password = True self.password = pywikibot.input( 'Password for user {name} on {site} (no characters will be ' 'shown):'.format(name=self.login_name, site=self.site), password=True) else: pywikibot.info(f'Logging in to {self.site} as {self.login_name}') try: self.login_to_site() except APIError as e: error_code = e.code # TODO: investigate other unhandled API codes if error_code in self._api_error: error_msg = 'Username {!r} {} on {}'.format( self.login_name, self._api_error[error_code], self.site) if error_code in ('Failed', 'FAIL'): error_msg += f'.\n{e.info}' raise NoUsernameError(error_msg) pywikibot.error(f'Login failed ({error_code}).') if retry: self.password = None return self.login(retry=False) else: self.storecookiedata() pywikibot.log('Should be logged in now') return True return False
[docs] class ClientLoginManager(LoginManager): """Supply login_to_site method to use API interface. .. versionchanged:: 8.0 2FA login was enabled. LoginManager was moved from :mod:`data.api` to :mod:`login` module and renamed to *ClientLoginManager*. """ # API login parameters mapping mapping = { 'user': ('lgname', 'username'), 'password': ('lgpassword', 'password'), 'ldap': ('lgdomain', 'domain'), 'token': ('lgtoken', 'logintoken'), 'result': ('result', 'status'), 'success': ('Success', 'PASS'), 'fail': ('Failed', 'FAIL'), 'reason': ('reason', 'message') }
[docs] def keyword(self, key): """Get API keyword from mapping.""" return self.mapping[key][self.action != 'login']
def _login_parameters(self, *, botpassword: bool = False ) -> dict[str, str]: """Return login parameters.""" if botpassword: self.action = 'login' else: self.action = 'clientlogin' # prepare default login parameters parameters = {'action': self.action, self.keyword('user'): self.login_name, self.keyword('password'): self.password} if self.action == 'login': parameters['lgtoken'] = self.site.tokens['login'] if self.action == 'clientlogin': # clientlogin requires non-empty loginreturnurl parameters['loginreturnurl'] = 'https://example.com' parameters['rememberMe'] = '1' parameters['logintoken'] = self.site.tokens['login'] if self.site.family.ldapDomain: parameters[self.keyword('ldap')] = self.site.family.ldapDomain return parameters
[docs] def login_to_site(self) -> None: """Login to the site. Note, this doesn't do anything with cookies. The http module takes care of all the cookie stuff. Throws exception on failure. .. versionchanged:: 8.0 2FA login was enabled. """ if hasattr(self, '_waituntil') \ and datetime.datetime.now() < self._waituntil: diff = self._waituntil - datetime.datetime.now() pywikibot.warning(f'Too many tries, waiting {diff.seconds}' ' seconds before retrying.') pywikibot.sleep(diff.seconds) self.site._loginstatus = LoginStatus.IN_PROGRESS # Bot passwords username contains @, # otherwise @ is not allowed in usernames. # @ in bot password is deprecated, # but we don't want to break bots using it. parameters = self._login_parameters( botpassword='@' in self.login_name or '@' in self.password) # base login request login_request = self.site._request(use_get=False, parameters=parameters) while True: # try to login try: login_result = login_request.submit() except pywikibot.exceptions.APIError as e: # pragma: no cover login_result = {'error': e.__dict__} # clientlogin response can be clientlogin or error if self.action in login_result: response = login_result[self.action] result_key = self.keyword('result') elif 'error' in login_result: response = login_result['error'] result_key = 'code' else: raise RuntimeError('Unexpected API login response key.') status = response[result_key] fail_reason = response.get(self.keyword('reason'), '') if status == self.keyword('success'): return if status in ('NeedToken', 'WrongToken', 'badtoken'): # if incorrect login token was used, # force relogin and generate fresh one pywikibot.error('Received incorrect login token. ' 'Forcing re-login.') # invalidate superior wiki cookies (T224712) pywikibot.data.api._invalidate_superior_cookies( self.site.family) self.site.tokens.clear() login_request[ self.keyword('token')] = self.site.tokens['login'] continue if status == 'UI': # pragma: no cover oathtoken = pywikibot.input(response['message'], password=True) login_request['OATHToken'] = oathtoken login_request['logincontinue'] = True del login_request['username'] del login_request['password'] del login_request['rememberMe'] continue # messagecode was introduced with 1.29.0-wmf.14 # but older wikis are still supported login_throttled = response.get('messagecode') == 'login-throttled' if (status == 'Throttled' or status == self.keyword('fail') and (login_throttled or 'wait' in fail_reason)): wait = response.get('wait') if wait: delta = datetime.timedelta(seconds=int(wait)) else: match = re.search(r'(\d+) (seconds|minutes)', fail_reason) if match: delta = datetime.timedelta(**{match[2]: int(match[1])}) else: delta = datetime.timedelta() self._waituntil = datetime.datetime.now() + delta break if 'error' in login_result: raise pywikibot.exceptions.APIError(**response) raise pywikibot.exceptions.APIError(code=status, info=fail_reason)
[docs] @deprecated("site.tokens['login']", since='8.0.0') def get_login_token(self) -> str | None: """Fetch login token. .. deprecated:: 8.0 :return: login token """ return self.site.tokens['login']
[docs] class BotPassword: """BotPassword object for storage in password file.""" def __init__(self, suffix: str, password: str) -> None: """ Initializer. BotPassword function by using a separate password paired with a suffixed username of the form <username>@<suffix>. :param suffix: Suffix of the login name :param password: bot password :raises _PasswordFileWarning: suffix improperly specified """ if '@' in suffix: warn('The BotPassword entry should only include the suffix', _PasswordFileWarning) self.suffix = suffix self.password = password
[docs] def login_name(self, username: str) -> str: """ Construct the login name from the username and suffix. :param user: username (without suffix) """ return f'{username}@{self.suffix}'
[docs] class OauthLoginManager(LoginManager): """Site login manager using OAuth.""" # NOTE: Currently OauthLoginManager use mwoauth directly to complete OAuth # authentication process def __init__(self, password: str | None = None, site: pywikibot.site.BaseSite | None = None, user: str | None = None) -> None: """ Initializer. All parameters default to defaults in user-config. :param site: Site object to log into :param user: consumer key :param password: consumer secret :raises pywikibot.exceptions.NoUsernameError: No username is configured for the requested site. :raises ImportError: mwoauth isn't installed """ if isinstance(mwoauth, ImportError): raise ImportError(f'mwoauth is not installed: {mwoauth}.') assert password is not None and user is not None super().__init__(password=None, site=site, user=None) if self.password: pywikibot.warn('Password exists in password file for {login.site}:' '{login.username}. Password is unnecessary and ' 'should be removed if OAuth enabled.' .format(login=self)) self._consumer_token = (user, password) self._access_token: tuple[str, str] | None = None
[docs] def login(self, retry: bool = False, force: bool = False) -> bool: """ Attempt to log into the server. .. seealso:: :api:`Login` :param retry: infinitely retry if exception occurs during authentication. :param force: force to re-authenticate """ if self.access_token is None or force: pywikibot.info( 'Logging in to {site} via OAuth consumer {key}' .format(key=self.consumer_token[0], site=self.site)) consumer_token = mwoauth.ConsumerToken(*self.consumer_token) handshaker = mwoauth.Handshaker( self.site.base_url(self.site.path()), consumer_token) try: redirect, request_token = handshaker.initiate() pywikibot.stdout('Authenticate via web browser..') webbrowser.open(redirect) pywikibot.stdout('If your web browser does not open ' 'automatically, please point it to: {}' .format(redirect)) request_qs = pywikibot.input('Response query string: ') access_token = handshaker.complete(request_token, request_qs) self._access_token = (access_token.key, access_token.secret) return True except Exception as e: pywikibot.error(e) if retry: return self.login(retry=True, force=force) return False else: pywikibot.info('Logged in to {site} via consumer {key}' .format(key=self.consumer_token[0], site=self.site)) return True
@property def consumer_token(self) -> tuple[str, str]: """ Return OAuth consumer key token and secret token. .. seealso:: :api:`Tokens` """ return self._consumer_token @property def access_token(self) -> tuple[str, str] | None: """ Return OAuth access key token and secret token. .. seealso:: :api:`Tokens` """ return self._access_token @property def identity(self) -> dict[str, Any] | None: """Get identifying information about a user via an authorized token.""" if self.access_token is None: pywikibot.error('Access token not set') return None consumer_token = mwoauth.ConsumerToken(*self.consumer_token) access_token = mwoauth.AccessToken(*self.access_token) try: identity = mwoauth.identify(self.site.base_url(self.site.path()), consumer_token, access_token) return identity except Exception as e: pywikibot.error(e) return None