| 1 | # -*- coding: iso8859-1 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2003-2005 Edgewall Software |
|---|
| 4 | # Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com> |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING, which |
|---|
| 8 | # you should have received as part of this distribution. The terms |
|---|
| 9 | # are also available at http://trac.edgewall.com/license.html. |
|---|
| 10 | # |
|---|
| 11 | # This software consists of voluntary contributions made by many |
|---|
| 12 | # individuals. For the exact contribution history, see the revision |
|---|
| 13 | # history and logs, available at http://projects.edgewall.com/trac/. |
|---|
| 14 | # |
|---|
| 15 | # Author: Jonas Borgström <jonas@edgewall.com> |
|---|
| 16 | |
|---|
| 17 | from __future__ import generators |
|---|
| 18 | import re |
|---|
| 19 | import time |
|---|
| 20 | |
|---|
| 21 | from trac.core import * |
|---|
| 22 | from trac.web.api import IAuthenticator, IRequestHandler |
|---|
| 23 | from trac.web.chrome import INavigationContributor |
|---|
| 24 | from trac.util import escape, hex_entropy, TRUE |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | class LoginModule(Component): |
|---|
| 28 | """Implements user authentication based on HTTP authentication provided by |
|---|
| 29 | the web-server, combined with cookies for communicating the login |
|---|
| 30 | information across the whole site. |
|---|
| 31 | |
|---|
| 32 | This plugin traps any /login path request and responds with a 401 basic |
|---|
| 33 | authentication header meaning that the client's browser has to provide |
|---|
| 34 | user credentials. The web-server should be set up in a way that these |
|---|
| 35 | credentials get validated (e.g. against a NT domain) before setting the |
|---|
| 36 | remote_user server variable. |
|---|
| 37 | The login name is then stored in the database and associated with a unique |
|---|
| 38 | key that gets passed back to the user agent using the 'trac_auth' cookie. |
|---|
| 39 | This cookie is used to identify the user in subsequent requests to |
|---|
| 40 | non-protected resources. |
|---|
| 41 | Please note: trac_auth cookies are not supported on IIS5, see |
|---|
| 42 | http://support.microsoft.com/?scid=kb%3Ben-us%3B176113&x=15&y=11. In this |
|---|
| 43 | case IIS5 must provide a valid remote_user with every request. |
|---|
| 44 | """ |
|---|
| 45 | |
|---|
| 46 | implements(IAuthenticator, INavigationContributor, IRequestHandler) |
|---|
| 47 | |
|---|
| 48 | # IAuthenticator methods |
|---|
| 49 | |
|---|
| 50 | def authenticate(self, req): |
|---|
| 51 | authname = None |
|---|
| 52 | |
|---|
| 53 | if req.remote_user: |
|---|
| 54 | authname = req.remote_user |
|---|
| 55 | elif req.incookie.has_key('trac_auth'): |
|---|
| 56 | authname = self._get_name_for_cookie(req, req.incookie['trac_auth']) |
|---|
| 57 | |
|---|
| 58 | if not authname: |
|---|
| 59 | return None |
|---|
| 60 | |
|---|
| 61 | ignore_case = self.env.config.get('trac', 'ignore_auth_case') |
|---|
| 62 | ignore_case = ignore_case.strip().lower() in TRUE |
|---|
| 63 | if ignore_case: |
|---|
| 64 | authname = authname.lower() |
|---|
| 65 | return authname |
|---|
| 66 | |
|---|
| 67 | # INavigationContributor methods |
|---|
| 68 | |
|---|
| 69 | def get_active_navigation_item(self, req): |
|---|
| 70 | return 'login' |
|---|
| 71 | |
|---|
| 72 | def get_navigation_items(self, req): |
|---|
| 73 | if req.authname and req.authname != 'anonymous': |
|---|
| 74 | yield 'metanav', 'login', 'logged in as %s' % escape(req.authname) |
|---|
| 75 | yield 'metanav', 'logout', '<a href="%s">Logout</a>' \ |
|---|
| 76 | % escape(self.env.href.logout()) |
|---|
| 77 | else: |
|---|
| 78 | yield 'metanav', 'login', '<a href="%s">Login</a>' \ |
|---|
| 79 | % escape(self.env.href.login()) |
|---|
| 80 | |
|---|
| 81 | # IRequestHandler methods |
|---|
| 82 | |
|---|
| 83 | def match_request(self, req): |
|---|
| 84 | return re.match('/(login|logout)/?', req.path_info) |
|---|
| 85 | |
|---|
| 86 | def process_request(self, req): |
|---|
| 87 | if req.path_info.startswith('/login'): |
|---|
| 88 | if not req.remote_user or req.remote_user == 'anonymous': |
|---|
| 89 | req.send_response(401) |
|---|
| 90 | req.send_header('WWW-Authenticate', 'Basic') |
|---|
| 91 | req.end_headers() |
|---|
| 92 | return |
|---|
| 93 | else: |
|---|
| 94 | self._do_login(req) |
|---|
| 95 | elif req.path_info.startswith('/logout'): |
|---|
| 96 | self._do_logout(req) |
|---|
| 97 | self._redirect_back(req) |
|---|
| 98 | |
|---|
| 99 | # Internal methods |
|---|
| 100 | |
|---|
| 101 | def _do_login(self, req): |
|---|
| 102 | """Log the remote user in. |
|---|
| 103 | |
|---|
| 104 | This function expects to be called when the remote user name is |
|---|
| 105 | available. The user name is inserted into the `auth_cookie` table and a |
|---|
| 106 | cookie identifying the user on subsequent requests is sent back to the |
|---|
| 107 | client. |
|---|
| 108 | |
|---|
| 109 | If the Authenticator was created with `ignore_case` set to true, then |
|---|
| 110 | the authentication name passed from the web server in req.remote_user |
|---|
| 111 | will be converted to lower case before being used. This is to avoid |
|---|
| 112 | problems on installations authenticating against Windows which is not |
|---|
| 113 | case sensitive regarding user names and domain names |
|---|
| 114 | """ |
|---|
| 115 | assert req.remote_user, 'Authentication information not available.' |
|---|
| 116 | |
|---|
| 117 | remote_user = req.remote_user |
|---|
| 118 | ignore_case = self.env.config.get('trac', 'ignore_auth_case') |
|---|
| 119 | ignore_case = ignore_case.strip().lower() in TRUE |
|---|
| 120 | if ignore_case: |
|---|
| 121 | remote_user = remote_user.lower() |
|---|
| 122 | |
|---|
| 123 | assert req.authname in ('anonymous', remote_user), \ |
|---|
| 124 | 'Already logged in as %s.' % req.authname |
|---|
| 125 | |
|---|
| 126 | cookie = hex_entropy() |
|---|
| 127 | db = self.env.get_db_cnx() |
|---|
| 128 | cursor = db.cursor() |
|---|
| 129 | cursor.execute("INSERT INTO auth_cookie (cookie,name,ipnr,time) " |
|---|
| 130 | "VALUES (%s, %s, %s, %s)", (cookie, remote_user, |
|---|
| 131 | req.remote_addr, int(time.time()))) |
|---|
| 132 | db.commit() |
|---|
| 133 | |
|---|
| 134 | req.authname = remote_user |
|---|
| 135 | req.outcookie['trac_auth'] = cookie |
|---|
| 136 | req.outcookie['trac_auth']['path'] = self.env.href() |
|---|
| 137 | |
|---|
| 138 | def _do_logout(self, req): |
|---|
| 139 | """Log the user out. |
|---|
| 140 | |
|---|
| 141 | Simply deletes the corresponding record from the auth_cookie table. |
|---|
| 142 | """ |
|---|
| 143 | if req.authname == 'anonymous': |
|---|
| 144 | # Not logged in |
|---|
| 145 | return |
|---|
| 146 | |
|---|
| 147 | # While deleting this cookie we also take the opportunity to delete |
|---|
| 148 | # cookies older than 10 days |
|---|
| 149 | db = self.env.get_db_cnx() |
|---|
| 150 | cursor = db.cursor() |
|---|
| 151 | cursor.execute("DELETE FROM auth_cookie WHERE name=%s OR time < %s", |
|---|
| 152 | (req.authname, int(time.time()) - 86400 * 10)) |
|---|
| 153 | db.commit() |
|---|
| 154 | self._expire_cookie(req) |
|---|
| 155 | |
|---|
| 156 | def _expire_cookie(self, req): |
|---|
| 157 | """Instruct the user agent to drop the auth cookie by setting the |
|---|
| 158 | "expires" property to a date in the past. |
|---|
| 159 | """ |
|---|
| 160 | req.outcookie['trac_auth'] = '' |
|---|
| 161 | req.outcookie['trac_auth']['path'] = self.env.href() |
|---|
| 162 | req.outcookie['trac_auth']['expires'] = -10000 |
|---|
| 163 | |
|---|
| 164 | def _get_name_for_cookie(self, req, cookie): |
|---|
| 165 | check_ip = self.env.config.get('trac', 'check_auth_ip') |
|---|
| 166 | check_ip = check_ip.strip().lower() in TRUE |
|---|
| 167 | |
|---|
| 168 | db = self.env.get_db_cnx() |
|---|
| 169 | cursor = db.cursor() |
|---|
| 170 | if check_ip: |
|---|
| 171 | cursor.execute("SELECT name FROM auth_cookie " |
|---|
| 172 | "WHERE cookie=%s AND ipnr=%s", |
|---|
| 173 | (cookie.value, req.remote_addr)) |
|---|
| 174 | else: |
|---|
| 175 | cursor.execute("SELECT name FROM auth_cookie WHERE cookie=%s", |
|---|
| 176 | (cookie.value,)) |
|---|
| 177 | row = cursor.fetchone() |
|---|
| 178 | if not row: |
|---|
| 179 | # The cookie is invalid (or has been purged from the database), so |
|---|
| 180 | # tell the user agent to drop it as it is invalid |
|---|
| 181 | self._expire_cookie(req) |
|---|
| 182 | return None |
|---|
| 183 | |
|---|
| 184 | return row[0] |
|---|
| 185 | |
|---|
| 186 | def _redirect_back(self, req): |
|---|
| 187 | """Redirect the user back to the URL she came from.""" |
|---|
| 188 | referer = req.get_header('Referer') |
|---|
| 189 | |
|---|
| 190 | # Since we cannot create a trac_auth cookie on IIS5, we depend |
|---|
| 191 | # on the remote_user information with every request. |
|---|
| 192 | # But the browser won't send authentication information when we |
|---|
| 193 | # redirect below the trac.cgi/ path. |
|---|
| 194 | if not self.env.abs_href.endswith('/'): |
|---|
| 195 | abs_href = self.env.abs_href + '/' |
|---|
| 196 | else: |
|---|
| 197 | abs_href = self.env.abs_href |
|---|
| 198 | |
|---|
| 199 | if referer: |
|---|
| 200 | if not referer.startswith(req.base_url): |
|---|
| 201 | # only redirect to referer if the latter is from the same |
|---|
| 202 | # instance |
|---|
| 203 | referer = None |
|---|
| 204 | else: |
|---|
| 205 | if not referer.endswith('/'): |
|---|
| 206 | referer = referer + '/' |
|---|
| 207 | req.redirect(referer or self.env.abs_href) |
|---|