├── LICENSE ├── README.md ├── app.yaml ├── cron.yaml ├── index.yaml ├── ios_profile_template.xml ├── main.py ├── models ├── __init__.py ├── password_generation.py ├── password_generation_history.py ├── password_keeper.py ├── session.py ├── setting.py └── webapp2_secret_key.py ├── password_crypto_helper.py ├── password_generator_helper.py ├── setup ├── deploy.pdf ├── download_dependencies.sh └── quicksheet.pdf ├── static ├── css │ └── admin.css ├── images │ ├── fav.png │ └── logo.png └── js │ ├── admin.js │ └── result.js ├── templates ├── admin.html ├── base.html ├── error.html ├── report.html ├── request.html ├── result.html └── thank_you.html └── xsrf_helper.py /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2014 Google, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # googleapps-password-generator 2 | 3 | ## Overview 4 | 5 | Many large Google Apps customers want to allow non-SAML capable devices to login to Google Apps (iOS, IMAP, etc), however they do not want to sync their corp passwords to Google. These customer also cannot use ASPs (appliction specific passwords) because they do not have ways to restrict how many ASPs are used by a user or how often ASPs are created. 6 | 7 | This Password Generator solution provides a self-service application customers can deploy to their end users to enable users to create a Google Apps password for the use with iOS, IMAP or other clients that require a password to be stored at Google. 8 | 9 | This project also contains examples of cross-site scripting (XSS) and cross-site request forgery (XSRF) protections implemented in an App Engine project. 10 | 11 | ## Key Features 12 | 13 | End user self-service password tool for creating Google Apps password. 14 | 15 | Automatically generate and configure iOS devices 16 | 17 | Support for configuring multiple iOS devices for a single user 18 | 19 | Google Group based access control 20 | 21 | Detailed Reporting 22 | 23 | ## Deployment 24 | 25 | Review [setup/deploy.pdf](https://github.com/google/googleapps-password-generator/blob/master/setup/deploy.pdf) for detailed setup and App Engine deployment instructions. 26 | 27 | ## Quick Sheet / Screen Shots 28 | 29 | Review [setup/quicksheet.pdf](https://github.com/google/googleapps-password-generator/blob/master/setup/quicksheet.pdf) for example screen shots of the project in action. 30 | 31 | ## Support 32 | 33 | For questions and answers join/view the 34 | [googleapps-password-generator Google Group](https://groups.google.com/forum/#!forum/googleapps-password-generator). 35 | 36 | 37 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 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 | application: password_generator 14 | version: 1 15 | runtime: python27 16 | threadsafe: true 17 | api_version: 1 18 | 19 | handlers: 20 | - url: /css 21 | static_dir: static/css 22 | login: required 23 | secure: always 24 | 25 | - url: /js 26 | static_dir: static/js 27 | login: required 28 | secure: always 29 | 30 | - url: /images 31 | static_dir: static/images 32 | login: required 33 | secure: always 34 | 35 | # cron task 36 | - url: /delete_expired_passwords 37 | script: main.application 38 | login: admin 39 | secure: always 40 | 41 | # cron task 42 | - url: /delete_expired_sessions 43 | script: main.application 44 | login: admin 45 | secure: always 46 | 47 | - url: .* 48 | script: main.application 49 | login: required 50 | secure: always 51 | 52 | 53 | libraries: 54 | - name: jinja2 55 | version: "latest" 56 | 57 | - name: pycrypto 58 | version: "latest" 59 | 60 | - name: webapp2 61 | version: "latest" 62 | 63 | skip_files: 64 | - ^(setup/.*) 65 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 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 | cron: 14 | - description: delete expired passwords 15 | url: /delete_expired_passwords 16 | schedule: every 15 minutes synchronized 17 | 18 | - description: delete expired webapp2 session datastore data 19 | url: /delete_expired_sessions 20 | schedule: every day 02:00 21 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 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 | indexes: 14 | 15 | - kind: PasswordGeneration 16 | properties: 17 | - name: user 18 | - name: date 19 | direction: desc 20 | -------------------------------------------------------------------------------- /ios_profile_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PayloadContent 6 | 7 | 8 | Host 9 | m.google.com 10 | MailNumberOfPastDaysToSync 11 | 0 12 | Password 13 | password 14 | PayloadDescription 15 | Configures device for use with Google Apps Sync Protocol. 16 | PayloadDisplayName 17 | Google Apps ActiveSync 18 | PayloadIdentifier 19 | uniqueID. 20 | PayloadOrganization 21 | 22 | PayloadType 23 | com.apple.eas.account 24 | PayloadUUID 25 | D6A65B3A-B9FB-4B3B-8C1F-4B8A877F2A59 26 | PayloadVersion 27 | 1 28 | PreventAppSheet 29 | 30 | PreventMove 31 | 32 | SMIMEEnabled 33 | 34 | SSL 35 | 36 | UserName 37 | user@domain.com 38 | EmailAddress 39 | user@domain.com 40 | 41 | 42 | PayloadDescription 43 | Configures device for use with Google Apps Sync Protocol. 44 | PayloadDisplayName 45 | Google Apps ActiveSync 46 | PayloadIdentifier 47 | uniqueID 48 | PayloadOrganization 49 | 50 | PayloadRemovalDisallowed 51 | 52 | PayloadType 53 | Configuration 54 | PayloadUUID 55 | 8EBAB0FF-73A6-4A0D-91D8-FB01AC27CCD8 56 | PayloadVersion 57 | 1 58 | 59 | 60 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/googleapps-password-generator/3e67041ece413cc01f40a0b05ce75c187c0ab61a/models/__init__.py -------------------------------------------------------------------------------- /models/password_generation.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 | """Models an individual password generation entity in appengine datastore.""" 14 | 15 | import cgi 16 | import logging 17 | 18 | from password_generation_history import PasswordGenerationHistory 19 | 20 | from google.appengine.ext import ndb 21 | 22 | _LOG = logging.getLogger('google_password_generator.password_generation') 23 | 24 | 25 | class PasswordGeneration(ndb.Model): 26 | """Models an individual password generation entity in appengine datastore. 27 | 28 | This will be used for tracking each attempt by users to generate new 29 | passwords or download the ios profile. 30 | """ 31 | 32 | user = ndb.UserProperty(required=True) 33 | date = ndb.DateTimeProperty(auto_now_add=True, required=True) 34 | reason = ndb.StringProperty(required=True) 35 | user_agent = ndb.StringProperty() 36 | user_ip_address = ndb.StringProperty() 37 | password_length = ndb.IntegerProperty() 38 | are_digits_used_for_password = ndb.StringProperty() 39 | is_punctuation_used_for_password = ndb.StringProperty() 40 | is_uppercase_used_for_password = ndb.StringProperty() 41 | 42 | @staticmethod 43 | def GetPasswordGenerationsByDate(start_datetime, end_datetime): 44 | """Get the password generations for the specified date range. 45 | 46 | Args: 47 | start_datetime: datetime object of the starting date to query 48 | end_datetime: datetime object of the ending date to query 49 | 50 | Returns: 51 | query result as a list containing password generation objects 52 | """ 53 | return PasswordGeneration.gql( 54 | 'WHERE date >= :1 and date <= :2 ORDER BY date DESC', 55 | start_datetime, end_datetime).fetch() 56 | 57 | @staticmethod 58 | def GetPasswordGenerationsByUser(user): 59 | """Get the password generations for the specified user. 60 | 61 | Args: 62 | user: appengine user object 63 | 64 | Returns: 65 | query result as a list containing password generation objects 66 | """ 67 | return PasswordGeneration.gql( 68 | 'WHERE user = :1 ORDER BY date DESC', user).fetch() 69 | 70 | @staticmethod 71 | def GetPasswordGenerationsByDateAndUser(start_datetime, end_datetime, user): 72 | """Get the password generations for the specified date range and user. 73 | 74 | Args: 75 | start_datetime: datetime object of the starting date to query 76 | end_datetime: datetime object of the ending date to query 77 | user: appengine user object 78 | 79 | Returns: 80 | query result as a list containing password generation objects 81 | """ 82 | return PasswordGeneration.gql( 83 | 'WHERE date >= :1 AND date <= :2 AND user = :3 ORDER BY date DESC', 84 | start_datetime, end_datetime, user).fetch() 85 | 86 | @staticmethod 87 | def StorePasswordGeneration(user, reason, user_agent, remote_addr, 88 | password_length, use_digits_in_password, 89 | use_punctuation_in_password, 90 | use_uppercase_in_password): 91 | """Store the attempt to generate a password which can be used for reporting. 92 | 93 | We set a parent key for the 'Password Generation' to ensure that they are 94 | all in the same entity group. This way, queries across the single 95 | entity group will be consistent. 96 | 97 | Args: 98 | user: appengine user object 99 | reason: string of the reason for generating the password 100 | user_agent: string of the browser user-agent 101 | remote_addr: string of the remote user's ip address 102 | password_length: integer of the password length 103 | use_digits_in_password: string of 'on' or 'off' 104 | use_punctuation_in_password: string of 'on' or 'off' 105 | use_uppercase_in_password: string of 'on' or 'off' 106 | """ 107 | password_generation = PasswordGeneration( 108 | parent=PasswordGenerationHistory.GetKey()) 109 | password_generation.user = user 110 | password_generation.reason = reason 111 | password_generation.user_agent = user_agent 112 | password_generation.user_ip_address = remote_addr 113 | password_generation.password_length = password_length 114 | password_generation.are_digits_used_for_password = use_digits_in_password 115 | password_generation.is_punctuation_used_for_password = ( 116 | use_punctuation_in_password) 117 | password_generation.is_uppercase_used_for_password = ( 118 | use_uppercase_in_password) 119 | password_generation.put() 120 | _LOG.info('Successfully logged password generation attempt.') 121 | _LOG.debug('The logged password generation is: %s', password_generation) 122 | 123 | @staticmethod 124 | def StoreIOSProfileDownloadEvent(user, user_agent, remote_addr): 125 | """Store the attempt to download ios profile for reporting. 126 | 127 | We set a parent key for the 'Password Generation' to ensure that they are 128 | all in the same entity group. This way, queries across the single 129 | entity group will be consistent. 130 | 131 | Args: 132 | user: appengine user object 133 | user_agent: string of the browser user-agent 134 | remote_addr: string of the remote user's ip address 135 | """ 136 | ios_profile_download = PasswordGeneration( 137 | parent=PasswordGenerationHistory.GetKey()) 138 | ios_profile_download.user = user 139 | ios_profile_download.reason = 'Download IOS Profile' 140 | ios_profile_download.user_agent = user_agent 141 | ios_profile_download.user_ip_address = remote_addr 142 | ios_profile_download.password_length = None 143 | ios_profile_download.are_digits_used_for_password = None 144 | ios_profile_download.is_punctuation_used_for_password = None 145 | ios_profile_download.is_uppercase_used_for_password = None 146 | ios_profile_download.put() 147 | _LOG.info('Successfully logged ios profile download attempt.') 148 | _LOG.debug('The logged ios_profile_download is: %s', ios_profile_download) 149 | -------------------------------------------------------------------------------- /models/password_generation_history.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 | """Models password generation history entity in appengine datastore.""" 14 | 15 | from google.appengine.ext import ndb 16 | 17 | 18 | class PasswordGenerationHistory(ndb.Model): 19 | """The parent entity for Password Generation. 20 | 21 | See _LogPasswordGeneration() why we need this, and how it is used. 22 | """ 23 | 24 | @staticmethod 25 | def GetKey(): 26 | """Return a datastore key for password generation history entity.""" 27 | return ndb.Key('PasswordGenerationHistory', 'password_generation_history') 28 | -------------------------------------------------------------------------------- /models/password_keeper.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 | """Models the encrypted password entity in appengine datastore.""" 14 | 15 | from datetime import datetime 16 | from datetime import timedelta 17 | import logging 18 | 19 | from google.appengine.ext import ndb 20 | 21 | _LOG = logging.getLogger('google_password_generator.password_keeper') 22 | 23 | 24 | class PasswordKeeper(ndb.Model): 25 | """Models the encrypted password entity in appengine datastore. 26 | 27 | This will be used for keeping the encrypted passwords for populating the 28 | ios profile template for multiple device configurations. 29 | """ 30 | date = ndb.DateTimeProperty(auto_now=True, required=True) 31 | encrypted_password = ndb.BlobProperty(required=True) 32 | crypto_initialization_vector = ndb.BlobProperty(required=True) 33 | 34 | @staticmethod 35 | def StorePassword(email, encrypted_password, crypto_initialization_vector): 36 | """Store the encrypted password and crypto info. 37 | 38 | Args: 39 | email: string of the user email for whom the password is being stored 40 | encrypted_password: byte string of the encrypted password 41 | crypto_initialization_vector: byte string of the crypto 42 | initialization vector 43 | """ 44 | password_keeper = PasswordKeeper(id=email) 45 | password_keeper.encrypted_password = encrypted_password 46 | password_keeper.crypto_initialization_vector = crypto_initialization_vector 47 | password_keeper.put() 48 | _LOG.info('Successfully stored encrypted password for %s.', email) 49 | 50 | @staticmethod 51 | def GetPassword(email): 52 | """Get the encrypted password for the specified email. 53 | 54 | Args: 55 | email: string of the user email 56 | 57 | Returns: 58 | datastore entity of the encrypted password 59 | """ 60 | return PasswordKeeper.get_by_id(email) 61 | 62 | @staticmethod 63 | def GetExpiredPasswords(): 64 | """Get the encrypted passwords that meets the expiration criteria. 65 | 66 | Returns: 67 | query result as a list containing password keeper entities 68 | """ 69 | cutoff_time_for_expired_passwords = ( 70 | datetime.utcnow() - timedelta(minutes=15)) 71 | _LOG.debug('The cutoff time for passwords to be expired is: %s', 72 | cutoff_time_for_expired_passwords.strftime('%m-%d-%Y %H:%M:%S')) 73 | return PasswordKeeper.gql('WHERE date <= :1', 74 | cutoff_time_for_expired_passwords).fetch() 75 | 76 | -------------------------------------------------------------------------------- /models/session.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 | """Models the webapp2 session entity in appengine datastore.""" 14 | 15 | from datetime import datetime 16 | from datetime import timedelta 17 | import logging 18 | 19 | from google.appengine.ext import ndb 20 | 21 | _LOG = logging.getLogger('google_password_generator.session') 22 | 23 | 24 | class Session(ndb.Model): 25 | """Models the webapp2 session entity in appengine datastore. 26 | 27 | The reason to model the entity is for query formation. 28 | 29 | A key information in the session data is the xsrf_token. 30 | """ 31 | data = ndb.BlobProperty(required=True) 32 | updated = ndb.DateTimeProperty(auto_now=True, required=True) 33 | 34 | @staticmethod 35 | def GetExpiredSessionKeys(): 36 | """Get the keys of the sessions that meet the expiration criteria. 37 | 38 | The keys will be used to perform bulk deletion. 39 | 40 | Returns: 41 | query result as a list containing session entity keys 42 | """ 43 | cutoff_time_for_expired_sessions = ( 44 | datetime.utcnow() - timedelta(hours=24)) 45 | _LOG.debug('The cutoff time for sessions to be expired is: %s', 46 | cutoff_time_for_expired_sessions.strftime('%m-%d-%Y %H:%M:%S')) 47 | return Session.gql('WHERE updated <= :1', 48 | cutoff_time_for_expired_sessions).fetch(keys_only=True) 49 | -------------------------------------------------------------------------------- /models/setting.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 | """Models individual setting values used to configure the application.""" 14 | 15 | import logging 16 | 17 | from wtforms import validators 18 | 19 | from google.appengine.ext import ndb 20 | 21 | _LOG = logging.getLogger('google_password_generator.setting') 22 | 23 | 24 | class Setting(ndb.Model): 25 | """Models the setting values used to configure the application. 26 | 27 | Each setting value would be in a field. 28 | 29 | There should be only one setting entity, with an id of current_settings. 30 | This also means that this setting entity is a root entity, without any parent. 31 | """ 32 | create_new_password_message = ndb.StringProperty(required=True) 33 | default_password_length = ndb.IntegerProperty(required=True) 34 | domain_admin_account = ndb.StringProperty(required=True) 35 | email_body_for_ios_profile_download_notification = ( 36 | ndb.StringProperty(required=True)) 37 | email_subject_for_ios_profile_download_notification = ( 38 | ndb.StringProperty(required=True)) 39 | enable_ios_profile_download = ndb.StringProperty() 40 | error_message = ndb.StringProperty(required=True) 41 | group_with_access_permission = ndb.StringProperty(required=True) 42 | ios_profile_template_filename = ndb.StringProperty(required=True) 43 | password_created_message = ndb.StringProperty(required=True) 44 | private_key_filename = ndb.StringProperty(required=True) 45 | remove_ambiguous_characters_in_password = ndb.StringProperty() 46 | service_account = ndb.StringProperty(required=True) 47 | thank_you_message = ndb.StringProperty(required=True) 48 | use_digits_in_password = ndb.StringProperty() 49 | use_punctuation_in_password = ndb.StringProperty() 50 | use_uppercase_in_password = ndb.StringProperty() 51 | 52 | @staticmethod 53 | def GetCurrentSettings(): 54 | """Return a setting entity, specifically the current_settings.""" 55 | return Setting.get_by_id('current_settings') 56 | 57 | @staticmethod 58 | def UpdateCurrentSettings(new_settings): 59 | """Update values for all setting entities. 60 | 61 | Note about checkbox form elements: 62 | Checkbox form elements are submitted in the form with a value of 'on'. 63 | They're left out of the post when they're unchecked, so the boolean value 64 | in the data store should be 'off'. 65 | See more: http://www.w3.org/TR/html401/interact/forms.html#h-17.2.1 66 | 67 | Args: 68 | new_settings: a dictionary of the new settings to be updated 69 | 70 | Returns: 71 | boolean, true if update has completed successfully 72 | """ 73 | current_settings = Setting(id='current_settings') 74 | current_settings.create_new_password_message = new_settings[ 75 | 'create_new_password_message'] 76 | current_settings.default_password_length = int(new_settings[ 77 | 'default_password_length']) 78 | current_settings.domain_admin_account = new_settings[ 79 | 'domain_admin_account'] 80 | 81 | if not new_settings['enable_ios_profile_download']: 82 | new_settings['enable_ios_profile_download'] = 'off' 83 | current_settings.enable_ios_profile_download = new_settings.get( 84 | 'enable_ios_profile_download') 85 | 86 | current_settings.email_subject_for_ios_profile_download_notification = ( 87 | new_settings.get('email_subject_for_ios_profile_download_notification')) 88 | current_settings.email_body_for_ios_profile_download_notification = ( 89 | new_settings.get('email_body_for_ios_profile_download_notification')) 90 | 91 | current_settings.error_message = new_settings['error_message'] 92 | current_settings.group_with_access_permission = new_settings[ 93 | 'group_with_access_permission'] 94 | current_settings.ios_profile_template_filename = new_settings[ 95 | 'ios_profile_template_filename'] 96 | current_settings.password_created_message = new_settings[ 97 | 'password_created_message'] 98 | current_settings.private_key_filename = new_settings[ 99 | 'private_key_filename'] 100 | 101 | if not new_settings['remove_ambiguous_characters_in_password']: 102 | new_settings['remove_ambiguous_characters_in_password'] = 'off' 103 | current_settings.remove_ambiguous_characters_in_password = new_settings.get( 104 | 'remove_ambiguous_characters_in_password') 105 | 106 | current_settings.service_account = new_settings['service_account'] 107 | current_settings.thank_you_message = new_settings['thank_you_message'] 108 | 109 | if not new_settings['use_digits_in_password']: 110 | new_settings['use_digits_in_password'] = 'off' 111 | current_settings.use_digits_in_password = new_settings.get( 112 | 'use_digits_in_password') 113 | 114 | if not new_settings['use_punctuation_in_password']: 115 | new_settings['use_punctuation_in_password'] = 'off' 116 | current_settings.use_punctuation_in_password = new_settings.get( 117 | 'use_punctuation_in_password') 118 | 119 | if not new_settings['use_uppercase_in_password']: 120 | new_settings['use_uppercase_in_password'] = 'off' 121 | current_settings.use_uppercase_in_password = new_settings.get( 122 | 'use_uppercase_in_password') 123 | 124 | current_settings.put() 125 | return True 126 | 127 | @staticmethod 128 | def GetAdditionalValidators(): 129 | """Get additional wtforms validators that we can use on the server-side.""" 130 | return { 131 | 'default_password_length': { 132 | 'validators': [validators.NumberRange(min=8, max=20)] 133 | }, 134 | 'domain_admin_account': { 135 | 'validators': [validators.Email()] 136 | }, 137 | 'group_with_access_permission': { 138 | 'validators': [validators.Email()] 139 | }, 140 | 'service_account': { 141 | 'validators': [validators.Email()] 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /models/webapp2_secret_key.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 | """Model for the secret key used to configure the webapp2.""" 14 | 15 | import logging 16 | import os 17 | 18 | from google.appengine.ext import ndb 19 | 20 | _LOG = logging.getLogger('google_password_generator.webapp2_secret_key') 21 | 22 | 23 | class Webapp2SecretKey(ndb.Model): 24 | """Model for the secret key used to configure the webapp2. 25 | 26 | There should be only one setting entity, with an id of current_secret_key. 27 | This also means that this setting entity is a root entity, without any parent. 28 | """ 29 | secret_key = ndb.StringProperty() 30 | 31 | @staticmethod 32 | def GetSecretKey(): 33 | """Get webapp2 secret key. 34 | 35 | Returns: 36 | A string of the webapp2 secret key. 37 | """ 38 | _LOG.info('Getting webapp2_secret_key.') 39 | return (Webapp2SecretKey.get_by_id('current_secret_key') 40 | .secret_key.encode('ascii', 'ignore')) 41 | 42 | @staticmethod 43 | def UpdateSecretKey(): 44 | """Update the webapp2 secret key. 45 | 46 | Returns: 47 | boolean, true if update has completed successfully 48 | """ 49 | _LOG.info('Updating webapp2_secret_key.') 50 | webapp2_secret_key = Webapp2SecretKey(id='current_secret_key') 51 | webapp2_secret_key.secret_key = os.urandom(16).encode('hex') 52 | webapp2_secret_key.put() 53 | return True 54 | -------------------------------------------------------------------------------- /password_crypto_helper.py: -------------------------------------------------------------------------------- 1 | """A helper class to encrypt and decrypt passwords. 2 | 3 | Example and documentation provided at the link below is pre-req for 4 | understanding this helper. 5 | 6 | https://www.dlitz.net/software/pycrypto/api/current/Crypto.Cipher.AES-module.html 7 | 8 | Some key points from the documentation: 9 | 10 | The block size determines the AES key size: 11 | 16 (AES-128), 24 (AES-192), or 32 (AES-256) 12 | 13 | MODE_CFB is chosen as the chaining mode, because it is recommended by the 14 | documentation and example is provided. crypto_initialization_vector is 15 | required for this mode. Otherwise, as a counter-example, the simpliest 16 | MODE_ECB doesn't need the crypto_initialization_vector, but it is deemed not 17 | as strong. 18 | 19 | https://www.dlitz.net/software/pycrypto/api/current/Crypto.Cipher.blockalgo-module.html#MODE_CFB 20 | https://www.dlitz.net/software/pycrypto/api/current/Crypto.Cipher.blockalgo-module.html#MODE_ECB 21 | 22 | The pycrypto cipher will handle the actual encryption and decryption processes. 23 | 24 | https://www.dlitz.net/software/pycrypto/api/current/Crypto.Cipher.AES.AESCipher-class.html 25 | """ 26 | 27 | import logging 28 | import os 29 | 30 | from Crypto.Cipher import AES 31 | 32 | 33 | _LOG = logging.getLogger('google_password_generator.password_crypto_helper') 34 | 35 | BLOCK_SIZE = 16 36 | 37 | 38 | class PasswordCryptoHelper(object): 39 | """A helper class to encrypt and decrypt passwords. 40 | 41 | The basic premise of how this helper works is that the password will be 42 | encrypted for storage, and can be retrieved later for decryption, based on 43 | the AES-128 standard. 44 | 45 | So, for the purpose of decryption, a password_key will be generated. This 46 | will be passed to the user, which will require the password_key to be 47 | encoded as base-64 strings. Later for decryption, the password_key will be 48 | passed back by the user, decoded back from string, and used by the cipher to 49 | decrypt the encrypted password. 50 | 51 | Because the crypto_initialization_vector is needed by the cipher, it will 52 | also be returned, so that it can be stored alongside the encrypted 53 | password. Then, it will also be retrieved later alongside the encrypted 54 | password so that it can be used by the cipher for decryption. 55 | """ 56 | 57 | @staticmethod 58 | def EncryptPassword(password): 59 | """Encrypt password. 60 | 61 | Args: 62 | password: a string of the password to be encrypted 63 | 64 | Returns: 65 | Byte string of the encrypted password, string of the password key, and 66 | byte string of the crypto initialization vector. 67 | """ 68 | password_key = os.urandom(BLOCK_SIZE) 69 | crypto_initialization_vector = os.urandom(BLOCK_SIZE) 70 | cipher = AES.new(password_key, AES.MODE_CFB, crypto_initialization_vector) 71 | encrypted_password = cipher.encrypt(password) 72 | _LOG.debug('Successfully encrypted password.') 73 | return (encrypted_password, password_key.encode('base-64'), 74 | crypto_initialization_vector) 75 | 76 | @staticmethod 77 | def DecryptPassword(encrypted_password_entity, password_key): 78 | """Decrypt password. 79 | 80 | Args: 81 | encrypted_password_entity: datastore entity of the encrypted password 82 | password_key: string of the password key 83 | 84 | Returns: 85 | decrypted_password: string of the decrypted password 86 | """ 87 | cipher = AES.new(password_key.decode('base-64'), AES.MODE_CFB, 88 | encrypted_password_entity.crypto_initialization_vector) 89 | decrypted_password = cipher.decrypt( 90 | encrypted_password_entity.encrypted_password) 91 | _LOG.debug('Successfully decrypted password.') 92 | return decrypted_password 93 | -------------------------------------------------------------------------------- /password_generator_helper.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 | """A helper class for the application logic used for password generator.""" 14 | 15 | import cgi 16 | 17 | 18 | class PasswordGeneratorHelper(object): 19 | """A helper class for the application logic used for password generator.""" 20 | 21 | @staticmethod 22 | def SanitizeText(text): 23 | """Sanitized a string and return it. 24 | 25 | Args: 26 | text: a string 27 | 28 | Returns: 29 | A sanitized string. 30 | """ 31 | return cgi.escape(text.strip(), quote=True) 32 | 33 | @staticmethod 34 | def IsIOSDevice(user_agent): 35 | """Determine if user_agent indicates iOS device. 36 | 37 | Args: 38 | user_agent: string of the user_agent 39 | 40 | Returns: 41 | True if user agent indicates ios device, or false otherwise. 42 | """ 43 | return 'iPhone' in user_agent or 'iPad' in user_agent 44 | 45 | @staticmethod 46 | def IsSafariBrowser(user_agent): 47 | """Determine if user_agent indicates Safari browser. 48 | 49 | Args: 50 | user_agent: string of the user_agent 51 | 52 | Returns: 53 | True if user agent indicates safari browser, or false otherwise. 54 | """ 55 | return 'Safari' in user_agent 56 | -------------------------------------------------------------------------------- /setup/deploy.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/googleapps-password-generator/3e67041ece413cc01f40a0b05ce75c187c0ab61a/setup/deploy.pdf -------------------------------------------------------------------------------- /setup/download_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | # Delete any previous dependency items 15 | if [ -d ../apiclient ]; then rm -rf ../apiclient ;fi 16 | if [ -d ../oauth2client ]; then rm -rf ../oauth2client ;fi 17 | if [ -d ../httplib2 ]; then rm -rf ../httplib2 ;fi 18 | if [ -d ../static/js/noty ]; then rm -rf ../static/js/noty ;fi 19 | if [ -f ../static/js/jquery-1.10.2.min.js ]; then rm ../static/js/jquery-1.10.2.min.js ;fi 20 | if [ -f ../static/js/jquery-1.10.2.min.map ]; then rm ../static/js/jquery-1.10.2.min.map ;fi 21 | if [ -d ../wtforms ]; then rm -rf ../wtforms ; fi 22 | if [ -d ../uritemplate ]; then rm -rf ../uritemplate ;fi 23 | 24 | # Download Google API Python Client 25 | curl http://google-api-python-client.googlecode.com/files/google-api-python-client-1.2.zip >google-api-python-client-1.2.zip 26 | unzip google-api-python-client-1.2.zip 27 | if [ -f google-api-python-client-1.2.zip ]; then rm google-api-python-client-1.2.zip ; fi 28 | mv google-api-python-client-1.2/apiclient ../ 29 | if [ -d google-api-python-client-1.2 ]; then rm -rf google-api-python-client-1.2 ;fi 30 | 31 | # Download Google OAuth 2 Client 32 | curl http://google-api-python-client.googlecode.com/files/oauth2client-1.2.zip >oauth2client-1.2.zip 33 | unzip oauth2client-1.2.zip 34 | if [ -f oauth2client-1.2.zip ]; then rm oauth2client-1.2.zip ;fi 35 | mv ./oauth2client-1.2/oauth2client ../ 36 | if [ -d oauth2client-1.2 ]; then rm -rf oauth2client-1.2 ;fi 37 | 38 | # Download httplib2 39 | curl https://pypi.python.org/packages/source/h/httplib2/httplib2-0.8.zip#md5=c92df9674a18f2b6e20ff2c5b7ada579 > httplib2-0.8.zip 40 | unzip httplib2-0.8.zip 41 | if [ -f httplib2-0.8.zip ]; then rm httplib2-0.8.zip ;fi 42 | if [ -d httplib2-0.8/python2/httplib2/test ]; then rm -rf httplib2-0.8/python2/httplib2/test ;fi 43 | mv httplib2-0.8/python2/httplib2 ../httplib2 44 | if [ -d httplib2-0.8 ]; then rm -rf httplib2-0.8 ;fi 45 | 46 | # Download noty 47 | curl -L https://github.com/needim/noty/archive/v2.1.0.zip > v2.1.0.zip 48 | unzip v2.1.0.zip 49 | mv noty-2.1.0/js/noty ../static/js 50 | if [ -f v2.1.0.zip ]; then rm v2.1.0.zip ;fi 51 | rm -rf noty-2.1.0 52 | 53 | # Download JQuery 54 | curl http://code.jquery.com/jquery-1.10.2.min.js >jquery-1.10.2.min.js 55 | curl http://code.jquery.com/jquery-1.10.2.min.map > jquery-1.10.2.min.map 56 | if [ ! -d ../static/js ]; then mkdir ../static/js ;fi 57 | mv jquery-1.10.2.min.js ../static/js 58 | mv jquery-1.10.2.min.map ../static/js 59 | 60 | # Download wtforms 61 | curl https://pypi.python.org/packages/source/W/WTForms/WTForms-1.0.5.zip#md5=a7ba0af8ed65267e5b421d34940d0151 > WTForms-1.0.5.zip 62 | unzip WTForms-1.0.5.zip 63 | mv ./WTForms-1.0.5/wtforms ../ 64 | if [ -f WTForms-1.0.5.zip ]; then rm WTForms-1.0.5.zip ;fi 65 | rm -rf WTForms-1.0.5 66 | 67 | # Download URITemplate 68 | curl -L https://github.com/uri-templates/uritemplate-py/archive/master.zip >master.zip 69 | unzip master.zip 70 | if [ -f master.zip ]; then rm master.zip ;fi 71 | mv uritemplate-py-master/uritemplate ../uritemplate 72 | if [ -d uritemplate-py-master ]; then rm -rf uritemplate-py-master ;fi 73 | 74 | -------------------------------------------------------------------------------- /setup/quicksheet.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/googleapps-password-generator/3e67041ece413cc01f40a0b05ce75c187c0ab61a/setup/quicksheet.pdf -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /static/images/fav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/googleapps-password-generator/3e67041ece413cc01f40a0b05ce75c187c0ab61a/static/images/fav.png -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/googleapps-password-generator/3e67041ece413cc01f40a0b05ce75c187c0ab61a/static/images/logo.png -------------------------------------------------------------------------------- /static/js/admin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Licensed under the Apache License, Version 2.0 (the "License"); 3 | * you may not use this file except in compliance with the License. 4 | * You may obtain a copy of the License at 5 | * 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Unless required by applicable law or agreed to in writing, software 9 | * distributed under the License is distributed on an "AS IS" BASIS, 10 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the License for the specific language governing permissions and 12 | * limitations under the License. 13 | */ 14 | 15 | 16 | /** 17 | * @fileoverview JS for the admin.html, i.e. admin console for settings. 18 | */ 19 | 20 | 21 | /** 22 | * Create a successful notification (noty). 23 | */ 24 | function PWG_GenerateSuccessfulNoty() { 25 | noty({ 26 | text: 'Settings saved.', 27 | type: 'warning', 28 | layout: 'topCenter', 29 | closeWith: ['button'], 30 | timeout: 3000 31 | }); 32 | } 33 | 34 | 35 | /** 36 | * Create a unsuccessful notification (noty). 37 | */ 38 | function PWG_GenerateUnsuccessfulNoty() { 39 | noty({ 40 | text: 'Error saving settings.', 41 | type: 'error', 42 | layout: 'topCenter', 43 | closeWith: ['button'], 44 | timeout: 3000 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /static/js/result.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Licensed under the Apache License, Version 2.0 (the "License"); 3 | * you may not use this file except in compliance with the License. 4 | * You may obtain a copy of the License at 5 | * 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Unless required by applicable law or agreed to in writing, software 9 | * distributed under the License is distributed on an "AS IS" BASIS, 10 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the License for the specific language governing permissions and 12 | * limitations under the License. 13 | */ 14 | 15 | 16 | /** 17 | * @fileoverview JS for the result.html page. 18 | */ 19 | 20 | 21 | /** 22 | * Fade out and hide the password, after initial page load. 23 | */ 24 | $(function() { 25 | $('#password').delay(60000).fadeOut('slow', function() { 26 | PWG_HidePassword($('#password').text()); 27 | }); 28 | setTimeout(PWG_ExpirePassword, 300000); 29 | }); 30 | 31 | 32 | /** 33 | * Hide the password, and show a reveal button. 34 | * @param {!string} password The newly generated password. 35 | */ 36 | function PWG_HidePassword(password) { 37 | var hidden_password = ''; 38 | for (var i = 0; i < password.length; i++) { 39 | hidden_password += '*'; 40 | } 41 | $('#password').html(hidden_password).fadeIn('fast'); 42 | $('#reveal_password_button').css('visibility', 'visible'); 43 | } 44 | 45 | 46 | /** 47 | * Hide the reveal button, and reveal the password. 48 | * @param {!string} password The newly generated password. 49 | */ 50 | function PWG_RevealPassword(password) { 51 | $('#reveal_password_button').css('visibility', 'hidden'); 52 | $('#password').html(password).fadeIn('fast').delay(60000) 53 | .fadeOut('slow', function() { 54 | PWG_HidePassword(password); 55 | }); 56 | } 57 | 58 | 59 | /** 60 | * Expires the displayed password, so that user can not see or access it. 61 | */ 62 | function PWG_ExpirePassword() { 63 | window.location.replace('/thank_you'); 64 | } 65 | -------------------------------------------------------------------------------- /templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "admin" %} 3 | {% block title %}Admininstration{% endblock title %} 4 | {% block head %} 5 | {{ super() }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% endblock head %} 14 | {% block teleport_links %} 15 | {{ super() }} 16 | Skip to password settings 18 | Skip to application settings 20 | Skip to messaging settings 22 | Skip to save settings 23 | {% endblock teleport_links %} 24 | {% block body %} 25 | {{ super() }} 26 |
27 |
28 |
29 | {% if update_is_successful is defined %} 30 | {% if update_is_successful %} 31 | 32 | 35 | {% else %} 36 | 39 | {% endif %} 40 | {% endif %} 41 |
42 |
43 |
    44 |
    45 |
    46 |

    Password settings

    47 | 69 | 92 | 118 | 140 | 166 |
    167 |
    168 |
    169 |

    Application settings

    170 | 194 | 221 | 246 | 273 |
  • 274 | 284 |
  • 285 | 312 | 338 | 361 |
    362 |
    363 |
    364 |

    Messaging settings

    365 |

    366 | Messages can only be plain-text. Html-formatted 367 | tags are not accepted. 368 |

    369 |
    370 |
    371 | 396 | 419 |
    420 |
    421 | 443 | 467 |
    468 |
    469 |
470 |
471 | 472 |
473 |
474 |
476 |
477 |
478 | 479 | {% endblock body %} 480 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% autoescape true %} 3 | 4 | 5 | {% block head %} 6 | 7 | 8 | 9 | Password Generator - {% block title %}{% endblock title %} 10 | 12 | 13 | 14 | 15 | {% endblock head %} 16 | 17 | 18 | {% block body %} 19 | {% block header %} 20 | 31 | 48 | {% endblock header %} 49 | {% endblock body %} 50 | 51 | 52 | {% endautoescape %} 53 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "error" %} 3 | {% block title %}Error{% endblock title %} 4 | {% block body %} 5 | {{ super () }} 6 |
7 |
8 |

Error!

9 |

{{ error_message }}

10 |
11 |
12 |

{{ exception_message }}

13 |
14 |
15 |
16 | {% endblock body %} 17 | -------------------------------------------------------------------------------- /templates/report.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "report" %} 3 | {% block title %}Reporting{% endblock title %} 4 | {% block head %} 5 | {{ super() }} 6 | {% endblock head %} 7 | {% block teleport_links %} 8 | {{ super() }} 9 | Skip to define the report start date 11 | Skip to define report end date 13 | Skip to define user email address 15 | Skip to generate report 16 | {% endblock teleport_links %} 17 | {% block body %} 18 | {{ super() }} 19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |

Password Generator Reports

27 |

28 | Search for password generations, by date only, by user only, 29 | or by date and user. 30 |


31 |
    32 | 55 | 77 | 100 |
101 |
102 | 103 |
104 |
105 | 106 |
107 |
108 |
109 | {% if password_generations %} 110 |

Password generation history

111 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | {% for password_generation in password_generations %} 128 | 129 | 136 | 143 | 150 | 157 | 164 | 171 | 172 | {% endfor %} 173 |
DateEmailReasonUser-AgentIP AddressPassword Length
130 | {% if password_generation.date %} 131 | {{ password_generation.date.strftime('%m/%d/%Y %H:%M') }} 132 | {% else %} 133 | Unrecorded 134 | {% endif %} 135 | 137 | {% if password_generation.user.email() %} 138 | {{ password_generation.user.email() }} 139 | {% else %} 140 | Unrecorded 141 | {% endif %} 142 | 144 | {% if password_generation.reason %} 145 | {{ password_generation.reason|truncate(30, True) }} 146 | {% else %} 147 | Unrecorded 148 | {% endif %} 149 | 151 | {% if password_generation.user_agent %} 152 | {{ password_generation.user_agent|truncate(30, True) }} 153 | {% else %} 154 | Unrecorded 155 | {% endif %} 156 | 158 | {% if password_generation.user_ip_address %} 159 | {{ password_generation.user_ip_address }} 160 | {% else %} 161 | Unrecorded 162 | {% endif %} 163 | 165 | {% if password_generation.password_length %} 166 | {{ password_generation.password_length }} 167 | {% else %} 168 | Unrecorded 169 | {% endif %} 170 |
174 | {% elif display_result_message %} 175 |

No results found. Please try again.

176 | {% endif %} 177 |
178 |
179 |
180 | 181 | {% endblock body %} 182 | -------------------------------------------------------------------------------- /templates/request.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "request" %} 3 | {% block title %}Generate{% endblock title %} 4 | {% block head %} 5 | {{ super() }} 6 | {% endblock head %} 7 | {% block teleport_links %} 8 | {{ super() }} 9 | Skip to set password reason 11 | {% endblock teleport_links %} 12 | {% block body %} 13 | {{ super () }} 14 |
15 |
16 |

Greetings, {{ user_fullname }}

17 |
18 |
19 |
20 |
21 |

Create a Google Password

22 |

{{ create_new_password_message }}

23 |
24 |
25 | 52 |
53 |
54 | 55 | 56 |
57 |
58 |
59 |
60 | {% endblock body %} 61 | -------------------------------------------------------------------------------- /templates/result.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "result" %} 3 | {% block title %}Generate{% endblock title %} 4 | {% block head %} 5 | {{ super() }} 6 | 7 | 8 | {% endblock head %} 9 | {% block teleport_links %} 10 | {{ super() }} 11 | Skip to password result 12 | {% endblock teleport_links %} 13 | {% block body %} 14 | {{ super () }} 15 |
16 |
17 |

Congratulations, {{ user_fullname }}

18 |
19 |
20 |

Your password has been created successfully!

21 |

{{ password_created_message }}

22 |
23 |
24 |
25 | 29 |
{{ password }}
30 | 35 | {% if enable_ios_profile_download == 'on' and is_ios_device and is_safari_browser %} 36 |
37 | Download iOS Configuration 38 | Profile in the safari browser of your iOS device to automatically 39 | configure your iOS device for use with Google Apps Sync Protocol. 40 |
41 | {% endif %} 42 |
43 |
44 |
45 | 50 |
51 |
52 |
53 |
54 | {% endblock body %} 55 | -------------------------------------------------------------------------------- /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 |
7 |

Thank you for using the password generator.

8 |
9 |
10 |

{{ thank_you_message }}

11 |
12 |
13 |
14 | {% endblock body %} 15 | -------------------------------------------------------------------------------- /xsrf_helper.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 | """A helper class to wrangle the xsrf utilities, and provide more logging.""" 14 | 15 | import logging 16 | 17 | from oauth2client import appengine 18 | from oauth2client import xsrfutil 19 | 20 | _LOG = logging.getLogger('google_password_generator.xsrf_helper') 21 | 22 | 23 | class XsrfHelper(object): 24 | """A helper class to wrangle the xsrf utilities, and provide more logging.""" 25 | 26 | def GenerateXsrfToken(self, current_user): 27 | """Generate a xsrf token. 28 | 29 | Args: 30 | current_user: Appengine user object of the current user. 31 | 32 | Returns: 33 | A string of the xsrf token. 34 | """ 35 | _LOG.info('Generating xsrf token for %s.', current_user.nickname()) 36 | xsrf_token = xsrfutil.generate_token(appengine.xsrf_secret_key(), 37 | current_user.user_id()) 38 | _LOG.debug('Successfully generated xsrf token for %s.', 39 | current_user.nickname()) 40 | return xsrf_token 41 | 42 | def _IsXsrfTokenWellFormedAndNotExpired(self, current_user, xsrf_token): 43 | """Determine if the submitted xsrf token is well-formed and has not expired. 44 | 45 | By well-formed, we mean if the the submitted xsrf token can be decoded and 46 | will match the generated xsrf token using the same criteria (i.e. check 47 | forgery). 48 | 49 | Args: 50 | current_user: Appengine user object of the current user. 51 | xsrf_token: A string of the xsrf token. 52 | 53 | Returns: 54 | A boolean, true if the token is well-formed and has not expired. 55 | Otherwise, false. 56 | """ 57 | is_xsrf_token_well_formed_and_not_expired = xsrfutil.validate_token( 58 | appengine.xsrf_secret_key(), xsrf_token, current_user.user_id()) 59 | _LOG.debug('Is xsrf token well-formed and not expired for %s: %s', 60 | current_user.nickname(), 61 | is_xsrf_token_well_formed_and_not_expired) 62 | return is_xsrf_token_well_formed_and_not_expired 63 | 64 | def _IsSubmittedXsrfTokenMatchingWithSessionXsrfToken(self, 65 | current_user, 66 | submitted_xsrf_token, 67 | session_xsrf_token): 68 | """Determine if the submitted xsrf token matches the xsrf token in session. 69 | 70 | Args: 71 | current_user: Appengine user object of the current user. 72 | submitted_xsrf_token: A string of the submitted xsrf token. 73 | session_xsrf_token: A string of the xsrf token stored in user session. 74 | 75 | Returns: 76 | A boolean, true if submitted xsrf token matches the xsrf token in session. 77 | Otherwise, false. 78 | """ 79 | if submitted_xsrf_token == session_xsrf_token: 80 | _LOG.debug('Submitted xsrf token matches the session xsrf token for %s.', 81 | current_user.nickname()) 82 | return True 83 | else: 84 | _LOG.debug('Submitted xsrf token does not match the session xsrf token ' 85 | 'for %s.', current_user.nickname()) 86 | return False 87 | 88 | def IsXsrfTokenValid(self, current_user, submitted_xsrf_token, 89 | session_xsrf_token): 90 | """Performs various checks to see if the submitted xsrf token is valid. 91 | 92 | Args: 93 | current_user: Appengine user object of the current user. 94 | submitted_xsrf_token: A string of the submitted xsrf token. 95 | session_xsrf_token: A string of the xsrf token stored in user session. 96 | 97 | Returns: 98 | A boolean, true if submitted xsrf token is valid. Otherwise, false. 99 | """ 100 | _LOG.info('Checking if xsrf token is valid for %s.', 101 | current_user.nickname()) 102 | return (self._IsXsrfTokenWellFormedAndNotExpired(current_user, 103 | submitted_xsrf_token) 104 | and self._IsSubmittedXsrfTokenMatchingWithSessionXsrfToken( 105 | current_user, submitted_xsrf_token, session_xsrf_token)) 106 | --------------------------------------------------------------------------------