├── 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 |
477 |
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 |
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 |
--------------------------------------------------------------------------------