├── models
├── __init__.py
├── password_generation_history.py
├── session.py
├── webapp2_secret_key.py
├── password_keeper.py
├── setting.py
└── password_generation.py
├── setup
├── deploy.pdf
├── quicksheet.pdf
└── download_dependencies.sh
├── static
├── images
│ ├── fav.png
│ └── logo.png
├── css
│ └── admin.css
└── js
│ ├── admin.js
│ └── result.js
├── templates
├── thank_you.html
├── error.html
├── base.html
├── request.html
├── result.html
├── report.html
└── admin.html
├── index.yaml
├── cron.yaml
├── app.yaml
├── README.md
├── password_generator_helper.py
├── ios_profile_template.xml
├── password_crypto_helper.py
├── xsrf_helper.py
├── LICENSE
└── main.py
/models/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/setup/deploy.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/googleapps-password-generator/HEAD/setup/deploy.pdf
--------------------------------------------------------------------------------
/setup/quicksheet.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/googleapps-password-generator/HEAD/setup/quicksheet.pdf
--------------------------------------------------------------------------------
/static/images/fav.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/googleapps-password-generator/HEAD/static/images/fav.png
--------------------------------------------------------------------------------
/static/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google/googleapps-password-generator/HEAD/static/images/logo.png
--------------------------------------------------------------------------------
/static/css/admin.css:
--------------------------------------------------------------------------------
1 | textarea#styled {
2 | width: 600px;
3 | height: 120px;
4 | border: 3px solid #cccccc;
5 | padding: 5px;
6 | font-family: Tahoma, sans-serif;
7 | background-image: url(bg.gif);
8 | background-position: bottom right;
9 | background-repeat: no-repeat;
10 | }
11 |
--------------------------------------------------------------------------------
/templates/thank_you.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% set active_page = "thank_you" %}
3 | {% block title %}Thank You{% endblock title %}
4 | {% block body %}
5 | {{ super () }}
6 |
27 |
28 |
478 |
479 | {% endblock body %}
480 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | # Licensed under the Apache License, Version 2.0 (the "License");
2 | # you may not use this file except in compliance with the License.
3 | # You may obtain a copy of the License at
4 | #
5 | # http://www.apache.org/licenses/LICENSE-2.0
6 | #
7 | # Unless required by applicable law or agreed to in writing, software
8 | # distributed under the License is distributed on an "AS IS" BASIS,
9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | # See the License for the specific language governing permissions and
11 | # limitations under the License.
12 |
13 | """An App Engine app to generate and update user's google domain passwords.
14 |
15 | This app enables users to generate new passwords for their google domain
16 | accounts, which they can then use for their non-saml clients.
17 |
18 | """
19 |
20 | import csv
21 | from datetime import datetime
22 | from datetime import timedelta
23 | import json
24 | import logging
25 | import os
26 | import random
27 | import re
28 | import string
29 | import StringIO
30 | import urllib
31 | import uuid
32 | import xml.etree.ElementTree as etree
33 |
34 | from apiclient import errors
35 | from apiclient.discovery import build
36 | import httplib2
37 | import jinja2
38 | from models.password_generation import PasswordGeneration
39 | from models.password_keeper import PasswordKeeper
40 | from models.session import Session
41 | from models.setting import Setting
42 | from models.webapp2_secret_key import Webapp2SecretKey
43 | from oauth2client import appengine
44 | from oauth2client import client
45 | from password_crypto_helper import PasswordCryptoHelper
46 | from password_generator_helper import PasswordGeneratorHelper
47 | import webapp2
48 | from webapp2_extras import sessions
49 | from wtforms import Form
50 | from wtforms import TextField
51 | from wtforms import validators
52 | from wtforms.ext.appengine.ndb import model_form
53 | from xsrf_helper import XsrfHelper
54 |
55 | from google.appengine.api import datastore_errors
56 | from google.appengine.api import mail
57 | from google.appengine.api import memcache
58 | from google.appengine.ext import ndb
59 | from google.appengine.api import users
60 |
61 |
62 | _LOG = logging.getLogger('google_password_generator.main')
63 |
64 | JINJA_ENVIRONMENT = jinja2.Environment(
65 | loader=jinja2.FileSystemLoader(
66 | os.path.join(os.path.dirname(__file__),
67 | 'templates')),
68 | extensions=['jinja2.ext.autoescape'])
69 |
70 | ADMIN_PAGE_BASE_URL = '/admin'
71 |
72 | VISUALLY_AMBIGUOUS_CHAR_SET = 'Il|1O0'
73 |
74 | DATABASE_EXCEPTION_LOG_MESSAGE = 'Encountered database exception: %s'
75 |
76 | DEFAULT_EMAIL_BODY_FOR_IOS_PROFILE_DOWNLOAD_NOTIFICATION = (
77 | 'Your requested password has been generated for you using Password'
78 | ' Generator. Use the link below to configure other iOS devices'
79 | ' using the password generated. NOTE: This link below will expire'
80 | ' in 15 minutes.')
81 |
82 | DEFAULT_EMAIL_SUBJECT_FOR_IOS_PROFILE_DOWNLOAD_NOTIFICATION = (
83 | 'Password Generator iOS Notification')
84 |
85 | DOMAIN_USER_INFO_NAMESPACE = 'pwg_domain_user_info_namespace'
86 |
87 | DOWNLOAD_REPORT_BASE_URL = '/download_report'
88 |
89 | DOWNLOAD_IOS_PROFILE_BASE_URL = '/download_ios_profile'
90 |
91 | GROUP_MEMBERSHIP_INFO_NAMESPACE = 'pwg_group_membership_info_namespace'
92 |
93 | HTML5_SAFE_DATE_FORMATTING = '%Y-%m-%d'
94 |
95 | IS_MEMCACHE_SET_LOG_MESSAGE = 'Is memcache set for %s: %s'
96 |
97 | # This will create a window where decommissioned admin or authorized group users
98 | # can still have access to the app.
99 | MEMCACHE_EXPIRATION_TIME_IN_SECONDS = 3600
100 |
101 | NO_ACCESS_TO_DOWNLOAD_IOS_PROFILE_ERROR_MESSAGE = (
102 | 'You do not have access to download iOS profile. Either the download '
103 | 'feature is not enabled, you are not using an iOS device, or you are not '
104 | 'using the Safari browser on iOS.')
105 |
106 | NO_ACCESS_TO_DOWNLOAD_IOS_PROFILE_LOG_MESSAGE = (
107 | 'No access to download iOS profile for %s. Either the download feature is '
108 | 'not enabled, this is not an iOS device, or this is not safari browser.')
109 |
110 | PASSWORD_CANNOT_BE_SAVED_ERROR_MESSAGE = (
111 | 'Your password can not be saved due to database error. Please try to '
112 | 'generate a new password again in a few moments.')
113 |
114 | PASSWORD_HAS_EXPIRED_ERROR_MESSAGE = ('Your iOS profile can not be downloaded '
115 | 'because your password has expired.')
116 |
117 | PASSWORD_HAS_EXPIRED_LOG_MESSAGE = 'Password has expired for %s.'
118 |
119 | USER_IS_APPENGINE_ADMIN_LOG_MESSAGE = '%s is appengine admin.'
120 |
121 | USER_IS_DOMAIN_ADMIN_LOG_MESSAGE = '%s is domain admin.'
122 |
123 | USER_IS_NOT_ADMIN_LOG_MESSAGE = ('%s (user_id: %s) is not admin and attempting '
124 | 'to access the page.')
125 |
126 | NO_ACCESS_TO_REQUESTED_PAGE_ERROR_MESSAGE = ('You do not have access to the '
127 | 'requested page.')
128 |
129 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE = (
130 | 'The security protection token provided in your request is invalid. Please '
131 | 'reload the website and try your request again.')
132 |
133 | XSRF_TOKEN_IS_INVALID_LOG_MESSAGE = (
134 | 'Rendering error page for %s (user_id: %s) due to invalid xsrf token.')
135 |
136 |
137 | def Handle401(request, response, exception): # pylint: disable=unused-argument
138 | """Returns a webapp2 response, with a 401 unauthorized http status.
139 |
140 | We are not logging the exception here because the actual http code might be
141 | 404 or 400, and is logged elsewhere. This is almost a stub method so that we
142 | can use the abort() to stop code execution.
143 |
144 | Args:
145 | request: webapp2 request object
146 | response: webapp2 response object
147 | exception: exception object
148 | """
149 | response.write('You have not been granted access to this service. '
150 | 'Please contact your Admin for access.')
151 | response.set_status(401)
152 |
153 |
154 | class ApiHelper(object):
155 | """Helper class for Google Apps APIs used by various handlers.
156 |
157 | Data structure of the domain user info is here.
158 | https://developers.google.com/admin-sdk/directory/v1/reference/users
159 | """
160 | API_SCOPE = (
161 | 'https://www.googleapis.com/auth/admin.directory.user '
162 | 'https://www.googleapis.com/auth/admin.directory.group.member.readonly')
163 | API_SERVICE_NAME = 'admin'
164 | DIRECTORY_API_VERSION = 'directory_v1'
165 |
166 | def _GetPrivateKey(self, private_key_filename):
167 | """Get the PEM certificate.
168 |
169 | Args:
170 | private_key_filename: string of the private key filename
171 |
172 | Returns:
173 | string content of the private key (i.e. PEM certificate)
174 | """
175 | with open(private_key_filename, 'rb') as f:
176 | return f.read()
177 |
178 | def _GetAuthorizedHttp(self, current_settings):
179 | """Get the authorized http from the signed jwt assertion credentials.
180 |
181 | The credentials will be stored in datastore. The client library will find
182 | it, validate it, and refresh it.
183 |
184 | Args:
185 | current_settings: An appengine datastore entity for the current_settings.
186 |
187 | Returns:
188 | authorized http
189 | """
190 | _LOG.info('Creating credentials storage in datastore.')
191 | credentials_in_storage = appengine.StorageByKeyName(
192 | appengine.CredentialsModel, 'password_generator', 'credentials')
193 |
194 | _LOG.debug('Getting credentials from storage.')
195 | credentials = credentials_in_storage.get()
196 | if credentials:
197 | _LOG.debug('Successfully got credentials from storage.')
198 | else:
199 | _LOG.debug('Credentials not in storage. Creating new credentials.')
200 | credentials = client.SignedJwtAssertionCredentials(
201 | current_settings.service_account,
202 | self._GetPrivateKey(current_settings.private_key_filename),
203 | self.API_SCOPE,
204 | sub=current_settings.domain_admin_account)
205 | credentials_in_storage.put(credentials)
206 | _LOG.debug('Successfully put credentials in storage.')
207 |
208 | return credentials.authorize(httplib2.Http())
209 |
210 | def _BuildDirectoryApiService(self, current_settings):
211 | """Build the directory api service.
212 |
213 | Args:
214 | current_settings: An appengine datastore entity for the current_settings.
215 |
216 | Returns:
217 | service object for interacting with the directory api
218 |
219 | Raises:
220 | InvalidPemException: An exception that that the PEM file content is not
221 | valid.
222 | """
223 | try:
224 | return build(
225 | serviceName=self.API_SERVICE_NAME,
226 | version=self.DIRECTORY_API_VERSION,
227 | http=self._GetAuthorizedHttp(current_settings))
228 | except NotImplementedError:
229 | ndb.Key('CredentialsModel', 'password_generator').delete()
230 | if memcache.flush_all():
231 | _LOG.debug('Memcache flushed successfully due to invalid service '
232 | 'account credentials.')
233 | else:
234 | _LOG.debug('Memcache not flushed successfully due to invalid service '
235 | 'account credentials.')
236 | raise Exception('The service account credentials are invalid. '
237 | 'Check to make the you have a valid PEM file and you '
238 | 'have removed any extra data attributes that may have '
239 | 'been written to the PEM file when converted from '
240 | 'PKCS12. The existing PEM key has been revoked and '
241 | 'needs to be updated with a new valid key.')
242 |
243 | def GetGroupMembershipInfo(self, current_user, current_settings):
244 | """Get the Google Group membership info of a user.
245 |
246 | The memcache will be checked first. If not in memcache, we will then
247 | make the api call, and then save into memcache for future use.
248 |
249 | Args:
250 | current_user: appengine user object
251 | current_settings: An appengine datastore entity for the current_settings.
252 |
253 | Returns:
254 | group_membership_info: A dictionary of the group membership info.
255 | https://developers.google.com/admin-sdk/directory/v1/reference/members
256 |
257 | Raises:
258 | HttpError: An error occurred when looking up group membership info by the
259 | apiclient library.
260 | """
261 | _LOG.info('Retrieving group membership for %s.', current_user.nickname())
262 | group_membership_info = memcache.get(
263 | current_user.email(),
264 | namespace=GROUP_MEMBERSHIP_INFO_NAMESPACE)
265 |
266 | if not group_membership_info:
267 | try:
268 | group_membership_info = self._BuildDirectoryApiService(
269 | current_settings).members().get(
270 | groupKey=current_settings.group_with_access_permission,
271 | memberKey=current_user.email()).execute()
272 | is_memcache_set = memcache.set(
273 | current_user.email(), group_membership_info,
274 | namespace=GROUP_MEMBERSHIP_INFO_NAMESPACE,
275 | time=MEMCACHE_EXPIRATION_TIME_IN_SECONDS)
276 | _LOG.debug(IS_MEMCACHE_SET_LOG_MESSAGE, 'group_membership_info',
277 | is_memcache_set)
278 | except errors.HttpError:
279 | raise
280 |
281 | _LOG.debug('Successfully retrieved group membership info for %s.',
282 | current_user.nickname())
283 | return group_membership_info
284 |
285 | def GetDomainUserInfo(self, current_user, current_settings):
286 | """Get the domain user info.
287 |
288 | The memcache will be checked first. If not in memcache, we will then
289 | make the api call, and then save into memcache for future use.
290 |
291 | Args:
292 | current_user: appengine user object
293 | current_settings: An appengine datastore entity for the current_settings.
294 |
295 | Returns:
296 | domain_user_info: A dictionary of the domain user info.
297 | """
298 | _LOG.info('Getting domain info for %s.', current_user.nickname())
299 | domain_user_info = memcache.get(current_user.email(),
300 | namespace=DOMAIN_USER_INFO_NAMESPACE)
301 |
302 | if not domain_user_info:
303 | domain_user_info = self._BuildDirectoryApiService(
304 | current_settings).users().get(userKey=current_user.email()).execute()
305 | is_memcache_set = memcache.set(
306 | current_user.email(), domain_user_info,
307 | namespace=DOMAIN_USER_INFO_NAMESPACE,
308 | time=MEMCACHE_EXPIRATION_TIME_IN_SECONDS)
309 | _LOG.debug(IS_MEMCACHE_SET_LOG_MESSAGE, 'domain_user_info',
310 | is_memcache_set)
311 |
312 | _LOG.debug('Domain user info: %s', domain_user_info)
313 | return domain_user_info
314 |
315 | def UpdateDomainUserInfo(self, current_user, current_settings,
316 | new_domain_user_info):
317 | """Updates the domain user info.
318 |
319 | Args:
320 | current_user: appengine user object
321 | current_settings: An appengine datastore entity for the current_settings.
322 | new_domain_user_info: A dictionary of the domain user with the new info
323 | to be updated.
324 | """
325 | _LOG.info('Updating domain info for %s.', current_user.nickname())
326 | updated_domain_user_info = self._BuildDirectoryApiService(
327 | current_settings).users().update(
328 | userKey=current_user.email(), body=new_domain_user_info).execute()
329 | _LOG.debug('Updated domain user info: %s', updated_domain_user_info)
330 |
331 |
332 | class PWGBaseHandler(webapp2.RequestHandler, XsrfHelper,
333 | PasswordGeneratorHelper):
334 | """Base handler that all the other handlers will subclass.
335 |
336 | Define the default template rendering.
337 |
338 | Define the default handling of exceptions by directing users to error page,
339 | show them the specific error, and log the stack trace for troubleshooting.
340 |
341 | The dispatch() and session() are adopted from webapp2 example.
342 | http://webapp-improved.appspot.com/guide/extras.html
343 | """
344 |
345 | def dispatch(self): # pylint: disable=g-bad-name
346 | self.session_store = sessions.get_store(request=self.request)
347 |
348 | try:
349 | webapp2.RequestHandler.dispatch(self)
350 | finally:
351 | self.session_store.save_sessions(self.response)
352 |
353 | @webapp2.cached_property
354 | def session(self): # pylint: disable=g-bad-name
355 | return self.session_store.get_session(backend='datastore')
356 |
357 | def _SanitizeFormAndFields(self, form):
358 | """Get a sanitized form and a dictionary of sanitized form fields.
359 |
360 | Args:
361 | form: a wtforms form object
362 |
363 | Returns:
364 | form: a sanitized wtforms form object
365 | sanitized_fields: a dictionary of sanitized form fields, where k,v is
366 | field.name,field.data
367 | """
368 | sanitized_fields = {}
369 | for field in form:
370 | if isinstance(field.data, basestring):
371 | field.data = self.SanitizeText(field.data)
372 | sanitized_fields.update({field.name: field.data})
373 |
374 | return form, sanitized_fields
375 |
376 | def _PreventUnauthorizedUserAccess(self):
377 | """Prevent unauthorized users from accessing this service.
378 |
379 | If user is a member of a group, then he has access to this service. The way
380 | that the directory members api works is that it will return a members object
381 | if the user is in the group. Otherwise, it will just throw HttpError if
382 | the user is not in the group. Will use a 401 code as a catch-all 4XX code
383 | for aborting further code execution.
384 |
385 | Raises:
386 | HttpError: An error occurred when looking up group membership info by the
387 | apiclient library.
388 | """
389 | _LOG.info('Checking if %s can access this service.',
390 | self.current_user.nickname())
391 | try:
392 | group_membership_info = ApiHelper().GetGroupMembershipInfo(
393 | self.current_user, self.current_settings)
394 | _LOG.info('%s is a %s of the group that can access this service.',
395 | self.current_user.nickname(), group_membership_info['role'])
396 | except errors.HttpError as e:
397 | error = json.loads(e.content).get('error')
398 | if error.get('code') == 404:
399 | _LOG.warning('%s does not have access to this service. The apiclient '
400 | 'is returning: %s, %s', self.current_user.nickname(),
401 | error.get('code'), error.get('message'))
402 | _LOG.exception(e)
403 | self.abort(401)
404 | else:
405 | raise
406 |
407 | def _AbortIfSettingDatastoreDoesNotExist(self, current_setting):
408 | """Abort request execution if setting datastore does not exist.
409 |
410 | The current_setting datastore entity will be checked. If it's null, then it
411 | means the setting datastore does not exist.
412 |
413 | Args:
414 | current_setting: A setting datastore entity representing current settings.
415 |
416 | Raises:
417 | Exception: A generic exception with message that datastore does not exist.
418 | """
419 | if not self.current_settings:
420 | is_appengine_admin = users.is_current_user_admin()
421 | if is_appengine_admin:
422 | exception_message = ('The setting database does not exist and needs to '
423 | 'be configured at the admin page: %s%s' %
424 | (self.request.host_url, ADMIN_PAGE_BASE_URL))
425 | else:
426 | exception_message = ('Please let your appengine admin know that the '
427 | 'setting database does not exist and needs to '
428 | 'be configured.')
429 | _LOG.warning(exception_message)
430 | raise Exception(exception_message)
431 |
432 | def _InitiatePWG(self):
433 | """Get and set the initial settings and values of the application state.
434 |
435 | We cannot retrieve these info in __init__() because any exception from these
436 | methods within __init__() will not be dispatched to handle_exception(),
437 | and will cause raw stack trace to be displayed to the user. So for a better
438 | user experience, we will opt to retrieve these info in the handlers
439 | instead of __init__().
440 |
441 | NB: This method always needs to be the first to be called in any handlers.
442 | """
443 | self.current_user = users.get_current_user()
444 |
445 | self.current_settings = Setting.GetCurrentSettings()
446 | self._AbortIfSettingDatastoreDoesNotExist(self.current_settings)
447 |
448 | self.domain_user_info = ApiHelper().GetDomainUserInfo(
449 | self.current_user, self.current_settings)
450 | self._PreventUnauthorizedUserAccess()
451 |
452 | def _RenderTemplate(self, html_filename, **kwargs):
453 | """Renders the template for the request page.
454 |
455 | We are not returning a response because overriding the dispatch() seems to
456 | have broken the response from being returned properly.
457 |
458 | Args:
459 | html_filename: the name of the html file to render
460 | **kwargs: dict of the items to render for the template, where the key is
461 | template field name, and the value is the template field value
462 |
463 | {user=self.current_user,
464 | all_settings=Setting.GetAllValues(),
465 | password_generations=password_generations,
466 | is_admin=is_admin}
467 |
468 | Returns:
469 | A response containing the rendered template.
470 | """
471 | _LOG.debug('Template values are: {0}'.format(kwargs))
472 | _LOG.info('Rendering {0} .'.format(html_filename))
473 | self.response.headers['X-Frame-Options'] = 'DENY'
474 | self.response.write(JINJA_ENVIRONMENT.get_template(
475 | html_filename).render(kwargs))
476 |
477 | def _RenderNoAccessIsAllowedErrorPage(self):
478 | _LOG.warning(USER_IS_NOT_ADMIN_LOG_MESSAGE, self.current_user.nickname(),
479 | self.current_user.user_id())
480 | self._RenderTemplate(
481 | 'error.html',
482 | error_message=NO_ACCESS_TO_REQUESTED_PAGE_ERROR_MESSAGE,
483 | is_admin=self.domain_user_info.get('isAdmin'))
484 |
485 | def _RenderErrorPage(self, log_message, error_message, is_admin):
486 | _LOG.warning('%s, %s', log_message, self.current_user.nickname())
487 | self._RenderTemplate(
488 | 'error.html',
489 | error_message=error_message,
490 | is_admin=is_admin)
491 |
492 | def handle_exception(self, exception, debug): # pylint: disable=g-bad-name
493 | _LOG.exception(exception)
494 | _LOG.debug('Is the web application in debug mode: {0}'.format(debug))
495 |
496 | # A best effort to get some settings so that we can display a more
497 | # user-friendly error page. Otherwise, display as clean a page as we can.
498 | try:
499 | self._RenderTemplate(
500 | 'error.html',
501 | error_message=self.current_settings.error_message,
502 | exception_message=exception,
503 | is_admin=self.domain_user_info.get('isAdmin'))
504 | except: # pylint: disable=bare-except
505 | self._RenderTemplate(
506 | 'error.html',
507 | exception_message=exception)
508 |
509 |
510 | class RequestPageHandler(PWGBaseHandler):
511 | """Handler for the initial/request page.
512 |
513 | This is responsible for generating the initial landing page for the user, who
514 | would request a new password to be generated, and then posted to the
515 | result page. The entry point is "/".
516 | """
517 |
518 | def get(self): # pylint: disable=g-bad-name
519 | """Get method for ResultPageHandler."""
520 | self._InitiatePWG()
521 | self.session['xsrf_token'] = self.GenerateXsrfToken(self.current_user)
522 | self._RenderTemplate(
523 | 'request.html',
524 | xsrf_token=self.session['xsrf_token'],
525 | user_fullname=self.domain_user_info['name']['fullName'],
526 | is_admin=self.domain_user_info.get('isAdmin'),
527 | create_new_password_message=
528 | self.current_settings.create_new_password_message)
529 |
530 |
531 | class ResultPageHandler(PWGBaseHandler):
532 | """Handler for the result page.
533 |
534 | This receives the post request from the request page for a new password. It
535 | will generate a random password, update it via the directory api, and then
536 | show the new password to the user. The entry point is "/result".
537 | """
538 |
539 | def __init__(self, request, response):
540 | super(ResultPageHandler, self).__init__(request, response)
541 | self.unsafe_characters_for_jinja = re.compile(r'(\'|"|\\|/|\.|<|>|`)')
542 | # hackery to make the pipe character replaceable without using raw prefix
543 | # pylint: disable=anomalous-backslash-in-string
544 | self.visually_ambiguous_char_set = re.compile(
545 | '|'.join(VISUALLY_AMBIGUOUS_CHAR_SET).replace('||', '|\|'))
546 | # pylint: enable=anomalous-backslash-in-string
547 |
548 | class PasswordGenerationForm(Form):
549 | reason = TextField('Reason', [validators.Required()])
550 |
551 | def _GenerateDownloadUrlForIOSProfile(self, xsrf_token, password_key):
552 | """Helper to cleanly build a URL.
553 |
554 | Args:
555 | xsrf_token: A string of the xsrf token.
556 | password_key: A string of the password key.
557 |
558 | Returns:
559 | A string for the URL base and parameters e.g.
560 | https://foobar.appspot.com/url_base?parameter1=abc¶meter2=xyz
561 | """
562 | return ''.join([self.request.host_url, DOWNLOAD_IOS_PROFILE_BASE_URL, '?',
563 | urllib.urlencode({'xsrf_token': xsrf_token,
564 | 'password_key': password_key})])
565 |
566 | def _EmailDownloadUrlForIOSProfile(self, download_url):
567 | """Email a link to download the iOS profile for user.
568 |
569 | Args:
570 | download_url: a string for the URL base and parameters e.g.
571 | https://foobar.appspot.com/url_base?parameter1=abc¶meter2=xyz
572 | """
573 | email_sender = self.current_settings.domain_admin_account
574 | email_recipient = self.current_user.email()
575 | email_subject = (self.current_settings
576 | .email_subject_for_ios_profile_download_notification)
577 | email_body = ''.join([self.current_settings
578 | .email_body_for_ios_profile_download_notification,
579 | '\n\n', download_url])
580 | mail.send_mail(email_sender, email_recipient, email_subject, email_body)
581 | _LOG.debug('Finished sending email to %s', email_recipient)
582 |
583 | def _BuildIOSProfile(self, decrypted_password):
584 | """Build a new iOS profile with new settings.
585 |
586 | The password has to be in cleartext in the iOS profile xml.
587 |
588 | The iOS profile template is xml, but it's rather unstructured.
589 | The data structure corresponds to the sample profile at:
590 | https://developer.apple.com/library/ios/featuredarticles
591 | /iPhoneConfigurationProfileRef/iPhoneConfigurationProfileRef.pdf
592 |
593 | For example:
594 |
595 |
596 |
597 |
598 |
599 |
600 | Host
601 | m.google.com
602 | MailNumberOfPastDaysToSync
603 | 0
604 | Password
605 | password
606 |
607 |
608 |
609 |
610 |
611 | An example of UUID that we will generate can be found here:
612 | http://en.wikipedia.org/wiki/Universally_unique_identifier#Definition
613 |
614 | Args:
615 | decrypted_password: string of the decrypted password
616 |
617 | Returns:
618 | A string of an iOS profile xml file with the new configurations.
619 | Data structure as above.
620 | """
621 | _LOG.info('Building iOS profile for %s.',
622 | self.current_user.nickname())
623 | ios_profile = etree.parse(
624 | self.current_settings.ios_profile_template_filename)
625 | ios_profile_strings = ios_profile.findall('.//string')
626 |
627 | # Note: If the xml schema changes, the elements might not be in the same
628 | # position.
629 | password = ios_profile_strings[1]
630 | password.text = decrypted_password
631 |
632 | payload_identifiers = [ios_profile_strings[4], ios_profile_strings[12]]
633 | for payload_identifier in payload_identifiers:
634 | payload_identifier.text = self.request.headers.get('User-Agent')
635 |
636 | new_uuid = str(uuid.uuid1())
637 | payload_uuids = [ios_profile_strings[7], ios_profile_strings[15]]
638 | for payload_uuid in payload_uuids:
639 | payload_uuid.text = new_uuid
640 |
641 | user_name_string = ios_profile_strings[8]
642 | user_name_string.text = self.current_user.email()
643 |
644 | email_address_string = ios_profile_strings[9]
645 | email_address_string.text = self.current_user.email()
646 |
647 | xml_header = ''
648 | xml_doctype = ('')
650 | ios_profile_instance = '\n'.join([xml_header, xml_doctype,
651 | etree.tostring(ios_profile.getroot())])
652 | return ios_profile_instance
653 |
654 | def DownloadIOSProfile(self):
655 | """Download a new instance of the iOS profile from template.
656 |
657 | The data structure corresponds to the sample profile at:
658 | https://developer.apple.com/library/ios/featuredarticles
659 | /iPhoneConfigurationProfileRef/iPhoneConfigurationProfileRef.pdf
660 |
661 | Returns:
662 | A response containing the rendered template.
663 | """
664 | self._InitiatePWG()
665 |
666 | if not (self.current_settings.enable_ios_profile_download == 'on' and
667 | self.IsIOSDevice(self.request.headers.get('User-Agent')) and
668 | self.IsSafariBrowser(self.request.headers.get('User-Agent'))):
669 | self._RenderErrorPage((NO_ACCESS_TO_DOWNLOAD_IOS_PROFILE_LOG_MESSAGE
670 | % self.current_user.nickname()),
671 | NO_ACCESS_TO_DOWNLOAD_IOS_PROFILE_ERROR_MESSAGE,
672 | self.domain_user_info.get('isAdmin'))
673 | elif not self.IsXsrfTokenValid(self.current_user,
674 | self.request.get('xsrf_token'),
675 | self.session.get('xsrf_token')):
676 | self._RenderErrorPage(XSRF_TOKEN_IS_INVALID_LOG_MESSAGE,
677 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE,
678 | self.domain_user_info.get('isAdmin'))
679 |
680 | else:
681 | try:
682 | _LOG.info('iOS profile download requested by: %s',
683 | self.current_user.nickname())
684 | decrypted_password = PasswordCryptoHelper.DecryptPassword(
685 | PasswordKeeper.GetPassword(self.current_user.email()),
686 | self.request.get('password_key'))
687 | downloadable_ios_profile = self._BuildIOSProfile(decrypted_password)
688 |
689 | self.response.headers['X-Frame-Options'] = 'DENY'
690 | self.response.headers['Content-Type'] = (
691 | 'application/x-apple-aspen-config')
692 | self.response.headers['Content-Disposition'] = (
693 | 'attachment; filename=ios_profile.xml')
694 | self.response.headers['Content-Length'] = len(downloadable_ios_profile)
695 | self.response.write(downloadable_ios_profile)
696 | PasswordGeneration.StoreIOSProfileDownloadEvent(
697 | self.current_user,
698 | self.SanitizeText(self.request.headers.get('User-Agent')),
699 | self.SanitizeText(self.request.remote_addr))
700 | except AttributeError:
701 | self._RenderErrorPage(
702 | (PASSWORD_HAS_EXPIRED_LOG_MESSAGE % self.current_user.email()),
703 | PASSWORD_HAS_EXPIRED_ERROR_MESSAGE,
704 | self.domain_user_info.get('isAdmin'))
705 |
706 | def _GenerateRandomPassword(self, current_settings):
707 | """Generates a random password.
708 |
709 | Args:
710 | current_settings: An appengine datastore entity for the current_settings.
711 |
712 | Returns:
713 | string of a new random password
714 | """
715 | password_char_set = string.ascii_lowercase
716 | if current_settings.use_uppercase_in_password == 'on':
717 | password_char_set += string.ascii_uppercase
718 | if current_settings.use_digits_in_password == 'on':
719 | password_char_set += string.digits
720 | if current_settings.use_punctuation_in_password == 'on':
721 | password_char_set += string.punctuation
722 | # Remove some punctuation characters so that password can be passed safely
723 | # in jinja template.
724 | password_char_set = self.unsafe_characters_for_jinja.sub(
725 | '', password_char_set)
726 |
727 | if current_settings.remove_ambiguous_characters_in_password == 'on':
728 | password_char_set = self.visually_ambiguous_char_set.sub(
729 | '', password_char_set)
730 |
731 | chars_for_new_password = []
732 | # pylint: disable=unused-variable
733 | for i in range(current_settings.default_password_length):
734 | chars_for_new_password.append(random.SystemRandom().choice(
735 | password_char_set))
736 | # pylint: enable=unused-variable
737 |
738 | return ''.join(chars_for_new_password)
739 |
740 | def _UpdateUserPassword(self, current_user, current_settings):
741 | """Updates the google apps password for the user.
742 |
743 | Using plaintext password, but is sent securely over encrypted SSL session
744 | to Google. Google will salt and hash and store the password. Using plain
745 | text with the API also allows Google Apps admins to see the password
746 | strength indicator of the password on a user account.
747 |
748 | Args:
749 | current_user: appengine user object
750 | current_settings: An appengine datastore entity for the current_settings.
751 |
752 | Returns:
753 | new_password: string of the new password that's been updated for user
754 | """
755 | new_password = self._GenerateRandomPassword(current_settings)
756 | self.domain_user_info['password'] = new_password
757 | ApiHelper().UpdateDomainUserInfo(current_user, current_settings,
758 | self.domain_user_info)
759 | PasswordGeneration.StorePasswordGeneration(
760 | current_user,
761 | self.SanitizeText(self.request.get('reason')),
762 | self.SanitizeText(self.request.headers.get('User-Agent')),
763 | self.SanitizeText(self.request.remote_addr),
764 | self.current_settings.default_password_length,
765 | self.SanitizeText(self.current_settings.use_digits_in_password),
766 | self.SanitizeText(self.current_settings.use_punctuation_in_password),
767 | self.SanitizeText(self.current_settings.use_uppercase_in_password))
768 | return new_password
769 |
770 | def post(self): # pylint: disable=g-bad-name
771 | """Post method for ResultPageHandler."""
772 | self._InitiatePWG()
773 | password_generation_form = self.PasswordGenerationForm(self.request.POST)
774 |
775 | if not self.IsXsrfTokenValid(self.current_user,
776 | self.request.get('xsrf_token'),
777 | self.session.get('xsrf_token')):
778 | self._RenderErrorPage(XSRF_TOKEN_IS_INVALID_LOG_MESSAGE,
779 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE,
780 | self.domain_user_info.get('isAdmin'))
781 |
782 | elif not password_generation_form.validate():
783 | _LOG.warning('Password generation form has failed validation.')
784 | self._RenderTemplate(
785 | 'request.html',
786 | xsrf_token=self.session.get('xsrf_token'),
787 | user_fullname=self.domain_user_info['name']['fullName'],
788 | password_generation_form=password_generation_form,
789 | is_admin=self.domain_user_info.get('isAdmin'))
790 |
791 | else:
792 | try:
793 | new_password = self._UpdateUserPassword(
794 | self.current_user, self.current_settings)
795 |
796 | if self.current_settings.enable_ios_profile_download == 'on':
797 | encrypted_password, password_key, crypto_initialization_vector = (
798 | PasswordCryptoHelper.EncryptPassword(new_password))
799 | PasswordKeeper.StorePassword(self.current_user.email(),
800 | encrypted_password,
801 | crypto_initialization_vector)
802 | download_url = self._GenerateDownloadUrlForIOSProfile(
803 | self.session['xsrf_token'], password_key)
804 | self._EmailDownloadUrlForIOSProfile(download_url)
805 | else:
806 | download_url = None
807 |
808 | self._RenderTemplate(
809 | 'result.html',
810 | user_fullname=self.domain_user_info['name']['fullName'],
811 | password=new_password,
812 | is_admin=self.domain_user_info.get('isAdmin'),
813 | password_created_message=
814 | self.current_settings.password_created_message,
815 | is_safari_browser=self.IsSafariBrowser(
816 | self.request.headers.get('User-Agent')),
817 | is_ios_device=self.IsIOSDevice(
818 | self.request.headers.get('User-Agent')),
819 | enable_ios_profile_download=
820 | self.current_settings.enable_ios_profile_download,
821 | download_url_for_ios_profile=download_url)
822 | except (datastore_errors.Timeout, datastore_errors.TransactionFailedError,
823 | datastore_errors.InternalError) as e:
824 | self._RenderErrorPage((DATABASE_EXCEPTION_LOG_MESSAGE % e),
825 | PASSWORD_CANNOT_BE_SAVED_ERROR_MESSAGE,
826 | self.domain_user_info.get('isAdmin'))
827 |
828 |
829 | class ReportPageHandler(PWGBaseHandler):
830 | """Handler for the report page.
831 |
832 | This is the page where the admin can run reports to see who has used this
833 | service. This handler will post to itself to do the actual querying and
834 | then show the result. The entry point is "/reporting".
835 | """
836 |
837 | class ReportingForm(Form):
838 | """WTForms form object for the reporting page search form."""
839 | start_date = TextField('Start Date', [validators.Optional()])
840 | end_date = TextField('End Date', [validators.Optional()])
841 | user_email = TextField('User Email',
842 | [validators.Optional(), validators.Email()])
843 |
844 | # WTForms implicitly calls validate_ to validate.
845 | def validate_start_date(self, start_date):
846 | """Validate that start date has end date for a proper search range."""
847 | if start_date.data and not self.end_date.data:
848 | raise validators.ValidationError('Please select an end date along '
849 | 'with the start date.')
850 |
851 | def validate_end_date(self, end_date):
852 | """Validate the end date against multiple criterias."""
853 | # Validate that end date has start date for a proper search range.
854 | if end_date.data and not self.start_date.data:
855 | raise validators.ValidationError('Please select a start date along '
856 | 'with the end date.')
857 |
858 | # Validate that end date is after start date.
859 | start_datetime = datetime.strptime(
860 | PasswordGeneratorHelper.SanitizeText(self.start_date.data),
861 | HTML5_SAFE_DATE_FORMATTING)
862 | end_datetime = datetime.strptime(
863 | PasswordGeneratorHelper.SanitizeText(end_date.data),
864 | HTML5_SAFE_DATE_FORMATTING)
865 | if end_datetime - start_datetime < timedelta(days=0):
866 | raise validators.ValidationError('Please select an end date after '
867 | 'the start date.')
868 |
869 | def _ConvertSearchInputsToValidQueryParameters(self):
870 | """Convert search inputs to valid search parameters.
871 |
872 | Returns:
873 | start_datetime: datetime object of the starting date to query
874 | end_datetime: datetime object of the ending date to query
875 | user: appengine user object
876 | """
877 | start_datetime = None
878 | end_datetime = None
879 | user = None
880 |
881 | start_date = self.SanitizeText(self.request.get('start_date'))
882 | end_date = self.SanitizeText(self.request.get('end_date'))
883 | user_email = self.SanitizeText(self.request.get('user_email'))
884 |
885 | if start_date:
886 | start_datetime = datetime.strptime(start_date, HTML5_SAFE_DATE_FORMATTING)
887 | end_datetime = (datetime.strptime(end_date, HTML5_SAFE_DATE_FORMATTING) +
888 | timedelta(hours=23, minutes=59, seconds=59))
889 | if user_email:
890 | user = users.User(email=user_email)
891 |
892 | return start_datetime, end_datetime, user
893 |
894 | def _GetPasswordGenerationsBasedOnSearchInputs(self):
895 | """Get the password generations based on the given search inputs.
896 |
897 | Returns:
898 | query result as an iterable containing password generation objects
899 | """
900 | start_datetime, end_datetime, user_email = (
901 | self._ConvertSearchInputsToValidQueryParameters())
902 |
903 | if start_datetime and user_email:
904 | _LOG.info('Search parameters start_date:%s end_date:%s user_email:%s',
905 | start_datetime, end_datetime, user_email)
906 | return PasswordGeneration.GetPasswordGenerationsByDateAndUser(
907 | start_datetime, end_datetime, user_email)
908 | elif start_datetime and not user_email:
909 | _LOG.info('Search parameters start_date:%s end_date:%s user_email:%s',
910 | start_datetime, end_datetime, '')
911 | return PasswordGeneration.GetPasswordGenerationsByDate(
912 | start_datetime, end_datetime)
913 | elif not start_datetime and user_email:
914 | _LOG.info('Search parameters start_date:%s end_date:%s user_email:%s',
915 | '', '', user_email)
916 | return PasswordGeneration.GetPasswordGenerationsByUser(user_email)
917 |
918 | def _GenerateDownloadURLForReport(self, reporting_form):
919 | """Helper to cleanly build a URL.
920 |
921 | Between the HTTP post and the Report Generation the date values are switched
922 | from strings to datetime objects to strings again. We need to change them
923 | back to the expected values to re-query the data.
924 |
925 | Args:
926 | reporting_form: The WTForm from generating a report.
927 |
928 | Returns:
929 | A string for the URL base and parameters e.g.
930 | /url_base?parameter1=abc¶meter2=xyz
931 | """
932 | url = {'xsrf_token': self.session.get('xsrf_token')}
933 | if reporting_form.start_date.data:
934 | url['start_date'] = reporting_form.start_date.data
935 |
936 | if reporting_form.end_date.data:
937 | url['end_date'] = reporting_form.end_date.data
938 |
939 | if reporting_form.user_email.data:
940 | url['user_email'] = reporting_form.user_email.data
941 |
942 | _LOG.debug('Detailed report URL: %s', urllib.urlencode(url))
943 |
944 | return ''.join([DOWNLOAD_REPORT_BASE_URL, '?', urllib.urlencode(url)])
945 |
946 | def _GenerateReportForDownload(self, password_generations):
947 | """Build a CSV report for download.
948 |
949 | Args:
950 | password_generations: A list containing password generation objects.
951 |
952 | Returns:
953 | report_handler: A file handler with csv data.
954 | """
955 | report_handler = StringIO.StringIO()
956 | report_keys = ['User Email', 'Date', 'Reason', 'User-Agent', 'IP Address',
957 | 'Password Length', 'Digits In Password',
958 | 'Punctuation In Password', 'Uppercase In Password']
959 | # If we write a row with a key not present in report_keys we ignore it with
960 | # extrasaction='ignore' being specified.
961 | report = csv.DictWriter(report_handler, report_keys,
962 | delimiter=',', quotechar='"',
963 | quoting=csv.QUOTE_ALL, restval='Unrecorded',
964 | extrasaction='ignore',)
965 | report.writeheader()
966 | for row in password_generations:
967 | row_values = [row.user.email(),
968 | row.date.strftime('%m/%d/%Y %H:%M'),
969 | row.reason,
970 | row.user_agent,
971 | row.user_ip_address,
972 | row.password_length,
973 | row.are_digits_used_for_password,
974 | row.is_punctuation_used_for_password,
975 | row.is_uppercase_used_for_password]
976 | report.writerow(dict(zip(report_keys, row_values)))
977 |
978 | return report_handler
979 |
980 | def get(self): # pylint: disable=g-bad-name
981 | """Get method for ReportPageHandler."""
982 | self._InitiatePWG()
983 | if self.domain_user_info['isAdmin']:
984 | _LOG.info(USER_IS_DOMAIN_ADMIN_LOG_MESSAGE, self.current_user.nickname())
985 | self.session['xsrf_token'] = self.GenerateXsrfToken(self.current_user)
986 | self._RenderTemplate(
987 | 'report.html',
988 | xsrf_token=self.session['xsrf_token'],
989 | user=self.current_user,
990 | reporting_form=self.ReportingForm(),
991 | is_admin=self.domain_user_info.get('isAdmin'))
992 | else:
993 | self._RenderNoAccessIsAllowedErrorPage()
994 |
995 | def post(self): # pylint: disable=g-bad-name
996 | """Post method for ReportPageHandler."""
997 | self._InitiatePWG()
998 | reporting_form = self.ReportingForm(self.request.POST)
999 |
1000 | if not self.domain_user_info['isAdmin']:
1001 | self._RenderNoAccessIsAllowedErrorPage()
1002 |
1003 | elif not self.IsXsrfTokenValid(self.current_user,
1004 | self.request.get('xsrf_token'),
1005 | self.session.get('xsrf_token')):
1006 | self._RenderErrorPage(XSRF_TOKEN_IS_INVALID_LOG_MESSAGE,
1007 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE,
1008 | self.domain_user_info.get('isAdmin'))
1009 |
1010 | elif reporting_form.validate():
1011 | password_generations = self._GetPasswordGenerationsBasedOnSearchInputs()
1012 | self._RenderTemplate(
1013 | 'report.html',
1014 | xsrf_token=self.session['xsrf_token'],
1015 | user=self.current_user,
1016 | reporting_form=reporting_form,
1017 | password_generations=password_generations,
1018 | is_admin=self.domain_user_info.get('isAdmin'),
1019 | display_result_message=True,
1020 | download_report_url=self._GenerateDownloadURLForReport(
1021 | reporting_form))
1022 |
1023 | else:
1024 | _LOG.warning('Reporting form failed validation. The form data are %s : ',
1025 | reporting_form.data)
1026 | self._RenderTemplate(
1027 | 'report.html',
1028 | xsrf_token=self.session['xsrf_token'],
1029 | user=self.current_user,
1030 | reporting_form=reporting_form,
1031 | is_admin=self.domain_user_info.get('isAdmin'))
1032 |
1033 | def DownloadReport(self):
1034 | """Generate and serve a report to the requestor."""
1035 | self._InitiatePWG()
1036 |
1037 | if not self.domain_user_info.get('isAdmin'):
1038 | self._RenderNoAccessIsAllowedErrorPage()
1039 |
1040 | elif not self.IsXsrfTokenValid(self.current_user,
1041 | self.request.get('xsrf_token'),
1042 | self.session.get('xsrf_token')):
1043 | self._RenderErrorPage(XSRF_TOKEN_IS_INVALID_LOG_MESSAGE,
1044 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE,
1045 | self.domain_user_info.get('isAdmin'))
1046 |
1047 | else:
1048 | report = self._GenerateReportForDownload(
1049 | self._GetPasswordGenerationsBasedOnSearchInputs())
1050 |
1051 | self.response.headers['X-Frame-Options'] = 'DENY'
1052 | self.response.headers['Content-Type'] = 'text/csv'
1053 | self.response.headers['Content-Disposition'] = ('attachement;'
1054 | 'filename=report.csv')
1055 | self.response.headers['Content-Length'] = report.len
1056 |
1057 | report.seek(0)
1058 | self.response.write(report.read())
1059 |
1060 |
1061 | class AdminPageHandler(PWGBaseHandler):
1062 | """Handler for the admin page.
1063 |
1064 | This shows the page where admin can change and update the various settings of
1065 | this service. This handler will post to itself to do the actual updating and
1066 | then show the result. The entry point is "/admin".
1067 |
1068 | Much application logic will use and depend on these setting values. But,
1069 | in order for these setting values to be initially configurable in the
1070 | datastore, the appengine admin is allowed to access and write these settings
1071 | directly to the datastore, i.e. bypass any logic that uses these settings.
1072 | """
1073 |
1074 | def __init__(self, request, response):
1075 | super(AdminPageHandler, self).__init__(request, response)
1076 | self.SettingsForm = model_form(Setting) # pylint: disable=g-bad-name
1077 |
1078 | def _GetEmptySettingsForm(self):
1079 | """Sets default values for admin settings when undefined."""
1080 |
1081 | create_new_password_message_text = ('To create your Google Apps password '
1082 | 'please provide a description below.')
1083 | password_created_message_text = 'Your new password is displayed below.'
1084 | error_message_text = ('There was an error. Please contact your '
1085 | 'administrator for assistance.')
1086 | thank_you_message_text = ('Thank you for using Password Generator!! To '
1087 | 'protect your security, you can no longer view '
1088 | 'the password that was generated. If you still '
1089 | 'need your password you will need to generate '
1090 | 'a new one.')
1091 |
1092 | return self.SettingsForm(
1093 | default_password_length=12,
1094 | private_key_filename='privatekey.pem',
1095 | ios_profile_template_filename='ios_profile_template.xml',
1096 | domain_admin_account='CHANGEME',
1097 | email_body_for_ios_profile_download_notification=
1098 | DEFAULT_EMAIL_BODY_FOR_IOS_PROFILE_DOWNLOAD_NOTIFICATION,
1099 | email_subject_for_ios_profile_download_notification=
1100 | DEFAULT_EMAIL_SUBJECT_FOR_IOS_PROFILE_DOWNLOAD_NOTIFICATION,
1101 | enable_ios_profile_download=None,
1102 | service_account='CHANGEME',
1103 | group_with_access_permission='CHANGEME',
1104 | use_digits_in_password='on',
1105 | use_punctuation_in_password='on',
1106 | use_uppercase_in_password='on',
1107 | remove_ambiguous_characters_in_password='on',
1108 | create_new_password_message=create_new_password_message_text,
1109 | password_created_message=password_created_message_text,
1110 | error_message=error_message_text,
1111 | thank_you_message=thank_you_message_text)
1112 |
1113 | def _GetSettingsFormPopulatedFromSettingDatastore(self, current_settings):
1114 | return self.SettingsForm(
1115 | default_password_length=current_settings.default_password_length,
1116 | private_key_filename=current_settings.private_key_filename,
1117 | ios_profile_template_filename=
1118 | current_settings.ios_profile_template_filename,
1119 | domain_admin_account=current_settings.domain_admin_account,
1120 | email_body_for_ios_profile_download_notification=
1121 | current_settings.email_body_for_ios_profile_download_notification,
1122 | email_subject_for_ios_profile_download_notification=
1123 | current_settings.email_subject_for_ios_profile_download_notification,
1124 | enable_ios_profile_download=
1125 | current_settings.enable_ios_profile_download,
1126 | service_account=current_settings.service_account,
1127 | group_with_access_permission=
1128 | current_settings.group_with_access_permission,
1129 | use_digits_in_password=current_settings.use_digits_in_password,
1130 | use_punctuation_in_password=
1131 | current_settings.use_punctuation_in_password,
1132 | use_uppercase_in_password=current_settings.use_uppercase_in_password,
1133 | remove_ambiguous_characters_in_password=
1134 | current_settings.remove_ambiguous_characters_in_password,
1135 | create_new_password_message=
1136 | current_settings.create_new_password_message,
1137 | password_created_message=current_settings.password_created_message,
1138 | error_message=current_settings.error_message,
1139 | thank_you_message=current_settings.thank_you_message)
1140 |
1141 | def _GetEmptySettingsFormOrPopulatedForm(self, current_settings):
1142 | if not current_settings:
1143 | return self._GetEmptySettingsForm()
1144 | else:
1145 | return self._GetSettingsFormPopulatedFromSettingDatastore(
1146 | self.current_settings)
1147 |
1148 | def _ValidateAndSaveSettingsForm(self, sanitized_settings_form,
1149 | sanitized_settings_fields):
1150 | update_is_successful = False
1151 | if sanitized_settings_form.validate():
1152 | _LOG.info('Request to save settings from %s. Saved settings: %s',
1153 | self.current_user.nickname(),
1154 | dict(self.request.POST.items()))
1155 | update_is_successful = Setting.UpdateCurrentSettings(
1156 | sanitized_settings_fields)
1157 | _LOG.debug('Are settings saved successfully for %s: %s',
1158 | self.current_user.nickname(), update_is_successful)
1159 | self._RenderTemplate(
1160 | 'admin.html',
1161 | xsrf_token=self.session.get('xsrf_token'),
1162 | user=self.current_user,
1163 | update_is_successful=update_is_successful,
1164 | visually_ambiguous_char_set=' '.join(VISUALLY_AMBIGUOUS_CHAR_SET),
1165 | punctuation_char_set=' '.join(string.punctuation),
1166 | settings_form=sanitized_settings_form,
1167 | is_admin=True)
1168 | else:
1169 | _LOG.debug('Unable to save settings for %s. '
1170 | 'Form validation failed for these fields and values:\n%s',
1171 | self.current_user.nickname(),
1172 | dict(self.request.POST.items()))
1173 | self._RenderTemplate(
1174 | 'admin.html',
1175 | xsrf_token=self.session.get('xsrf_token'),
1176 | user=self.current_user,
1177 | update_is_successful=update_is_successful,
1178 | visually_ambiguous_char_set=' '.join(VISUALLY_AMBIGUOUS_CHAR_SET),
1179 | punctuation_char_set=' '.join(string.punctuation),
1180 | settings_form=sanitized_settings_form,
1181 | is_admin=True)
1182 |
1183 | def get(self): # pylint: disable=g-bad-name
1184 | """Get method for AdminPageHandler."""
1185 | self.current_user = users.get_current_user()
1186 | self.session['xsrf_token'] = self.GenerateXsrfToken(self.current_user)
1187 |
1188 | is_appengine_admin = users.is_current_user_admin()
1189 | if is_appengine_admin:
1190 | _LOG.info(USER_IS_APPENGINE_ADMIN_LOG_MESSAGE,
1191 | self.current_user.nickname())
1192 | self.current_settings = Setting.GetCurrentSettings()
1193 | self._RenderTemplate(
1194 | 'admin.html',
1195 | xsrf_token=self.session['xsrf_token'],
1196 | user=self.current_user,
1197 | visually_ambiguous_char_set=' '.join(VISUALLY_AMBIGUOUS_CHAR_SET),
1198 | punctuation_char_set=' '.join(string.punctuation),
1199 | settings_form=self._GetEmptySettingsFormOrPopulatedForm(
1200 | self.current_settings),
1201 | is_admin=is_appengine_admin)
1202 | else:
1203 | self._InitiatePWG()
1204 | if self.domain_user_info['isAdmin']:
1205 | _LOG.info(USER_IS_DOMAIN_ADMIN_LOG_MESSAGE,
1206 | self.current_user.nickname())
1207 | self._RenderTemplate(
1208 | 'admin.html',
1209 | xsrf_token=self.session['xsrf_token'],
1210 | user=self.current_user,
1211 | visually_ambiguous_char_set=' '.join(VISUALLY_AMBIGUOUS_CHAR_SET),
1212 | punctuation_char_set=' '.join(string.punctuation),
1213 | settings_form=self._GetSettingsFormPopulatedFromSettingDatastore(
1214 | self.current_settings),
1215 | is_admin=self.domain_user_info.get('isAdmin'))
1216 | else:
1217 | self._RenderNoAccessIsAllowedErrorPage()
1218 |
1219 | def post(self): # pylint: disable=g-bad-name
1220 | """Post method for AdminPageHandler."""
1221 |
1222 | self.SettingsForm = model_form(
1223 | Setting,
1224 | Form,
1225 | field_args=Setting.GetAdditionalValidators())
1226 | settings_form = self.SettingsForm(self.request.POST)
1227 | sanitized_settings_form, sanitized_settings_fields = (
1228 | self._SanitizeFormAndFields(settings_form))
1229 |
1230 | self.current_user = users.get_current_user()
1231 | if not self.IsXsrfTokenValid(self.current_user,
1232 | self.request.get('xsrf_token'),
1233 | self.session.get('xsrf_token')):
1234 | self._RenderErrorPage(XSRF_TOKEN_IS_INVALID_LOG_MESSAGE,
1235 | XSRF_TOKEN_IS_INVALID_ERROR_MESSAGE,
1236 | True)
1237 | else:
1238 | is_appengine_admin = users.is_current_user_admin()
1239 | if is_appengine_admin:
1240 | _LOG.info(USER_IS_APPENGINE_ADMIN_LOG_MESSAGE,
1241 | self.current_user.nickname())
1242 | self._ValidateAndSaveSettingsForm(sanitized_settings_form,
1243 | sanitized_settings_fields)
1244 | else:
1245 | self._InitiatePWG()
1246 | if self.domain_user_info['isAdmin']:
1247 | self._ValidateAndSaveSettingsForm(sanitized_settings_form,
1248 | sanitized_settings_fields)
1249 | else:
1250 | self._RenderNoAccessIsAllowedErrorPage()
1251 |
1252 |
1253 | class ThankYouPageHandler(PWGBaseHandler):
1254 | """Handler for the thank you page.
1255 |
1256 | This is responsible for generating the thank you page, which will be shown to
1257 | users after their passwords have expired. The entry point is "/thank_you".
1258 | """
1259 |
1260 | def get(self): # pylint: disable=g-bad-name
1261 | """Get method for ThankYouPageHandler."""
1262 | self._InitiatePWG()
1263 | self._RenderTemplate(
1264 | 'thank_you.html',
1265 | user=self.current_user,
1266 | is_admin=self.domain_user_info.get('isAdmin'),
1267 | thank_you_message=self.current_settings.thank_you_message)
1268 |
1269 |
1270 | class LandingPageHandler(PWGBaseHandler):
1271 | """Handler to determine which landing page to present to users.
1272 |
1273 | Take admin users to the admin page. Otherwise, the request page for others.
1274 | """
1275 |
1276 | def get(self): # pylint: disable=g-bad-name
1277 | """Get method for LandingPageHandler."""
1278 | self._InitiatePWG()
1279 | if self.domain_user_info.get('isAdmin'):
1280 | return self.redirect(ADMIN_PAGE_BASE_URL)
1281 | else:
1282 | return self.redirect('/request')
1283 |
1284 |
1285 | class DeleteExpiredPasswordsHandler(PWGBaseHandler):
1286 | """Handler for cron service to delete expired passwords."""
1287 |
1288 | def get(self):
1289 | """Get method for DeleteExpiredPasswordsHandler."""
1290 | _LOG.debug('Start deleting expired passwords.')
1291 | for expired_password_entity in PasswordKeeper.GetExpiredPasswords():
1292 | email = expired_password_entity.key.id()
1293 | last_updated_time = expired_password_entity.date.strftime(
1294 | '%m-%d-%Y %H:%M:%S')
1295 | expired_password_entity.key.delete()
1296 | _LOG.debug('Expired password for %s has been deleted. Its last '
1297 | 'update was at %s.', email, last_updated_time)
1298 | _LOG.debug('Finished deleting expired passwords.')
1299 |
1300 |
1301 | class DeleteExpiredSessionsHandler(PWGBaseHandler):
1302 | """Handler for cron service to delete expired sessions."""
1303 |
1304 | def get(self):
1305 | """Get method for DeleteExpiredSessionsHandler."""
1306 | _LOG.debug('Start deleting expired sessions.')
1307 | ndb.delete_multi(Session.GetExpiredSessionKeys())
1308 | _LOG.debug('Finished deleting expired sessions.')
1309 |
1310 |
1311 | def GetWebapp2ConfigSecretKey():
1312 | """Get the webapp2 config secret key.
1313 |
1314 | Blaze test will throw AssertionError: No api proxy found for service
1315 | "memcache". So, just fake a dummy value for testing.
1316 |
1317 | Returns:
1318 | A string of secret key.
1319 | """
1320 | try:
1321 | return Webapp2SecretKey.GetSecretKey()
1322 | except AttributeError:
1323 | Webapp2SecretKey.UpdateSecretKey()
1324 | return Webapp2SecretKey.GetSecretKey()
1325 | except AssertionError:
1326 | return os.urandom(16).encode('hex')
1327 |
1328 |
1329 | config = {}
1330 | config['webapp2_extras.sessions'] = {'secret_key': GetWebapp2ConfigSecretKey(),
1331 | 'cookie_args': {'secure': True}}
1332 |
1333 | application = webapp2.WSGIApplication(
1334 | [(ADMIN_PAGE_BASE_URL, AdminPageHandler),
1335 | ('/delete_expired_passwords', DeleteExpiredPasswordsHandler),
1336 | ('/delete_expired_sessions', DeleteExpiredSessionsHandler),
1337 | ('/reporting', ReportPageHandler),
1338 | ('/request', RequestPageHandler),
1339 | ('/result', ResultPageHandler),
1340 | ('/thank_you', ThankYouPageHandler),
1341 | webapp2.Route(DOWNLOAD_IOS_PROFILE_BASE_URL,
1342 | handler=ResultPageHandler,
1343 | handler_method='DownloadIOSProfile'),
1344 | webapp2.Route(DOWNLOAD_REPORT_BASE_URL,
1345 | handler=ReportPageHandler,
1346 | handler_method='DownloadReport'),
1347 | ('/', LandingPageHandler)],
1348 | debug=False, config=config)
1349 | application.error_handlers[401] = Handle401
1350 |
--------------------------------------------------------------------------------