· 6 years ago · Mar 19, 2019, 07:46 AM
1// Callback has to go to an https URL
2if (!serverProperties.useSSL) {
3 // TODO: maybe instead disable OAuth2 if useSSL is false?
4 throw new RuntimeException("Google OAuth2 callback requires useSSL to be set to true");
5}
6
7// Read Google OAuth secrets
8final var googleSecretsFile = new File(serverProperties.googleSecretProperties);
9if (!googleSecretsFile.exists()) {
10 throw new RuntimeException(
11 "Google secrets file does not exist: " + serverProperties.googleSecretProperties);
12}
13final var googleSecrets = new Properties();
14try (var inputStream = new FileInputStream(googleSecretsFile)) {
15 googleSecrets.load(inputStream);
16} catch (final IOException e) {
17 throw new RuntimeException("Cannot read " + googleSecretsFile + ": " + e);
18}
19final var clientId = googleSecrets.getProperty("clientId");
20final var clientSecret = googleSecrets.getProperty("clientSecret");
21if (clientId == null || clientSecret == null) {
22 // TODO: currently Google OAuth must be set up
23 throw new RuntimeException("Please provide Google OAuth credentials in file " + googleSecretsFile);
24}
25// Create Google OAuth provider
26final var googleAuthProvider = GoogleAuth.create(vertx, clientId, clientSecret);
27
28// Local cache of id_token to user id mapping
29final var idTokenToUserId = new ConcurrentHashMap<String, String>();
30
31// Local cache of id_token to user id mapping
32final var idTokenToAuthorities = new ConcurrentHashMap<String, JsonObject>(); // TODO add cache invalidation or update method
33
34// Create RBAC handler
35googleAuthProvider.rbacHandler(new OAuth2RBAC() {
36 @Override
37 public void isAuthorized(AccessToken user, String requiredAuthority,
38 Handler<AsyncResult<Boolean>> handler) {
39 // Check if user's authentication token has expired
40 if (user.expired()) {
41 user.clearCache();
42 handler.handle(Future.failedFuture("Token expired"));
43 return;
44 }
45
46 // Get id_token from principal
47 var idToken = user.principal().getString("id_token");
48 if (idToken == null) {
49 handler.handle(Future.failedFuture("Could not get id_token from principal"));
50 return;
51 }
52
53 // Look up cached authorities
54 var authorities = idTokenToAuthorities.get(idToken);
55 if (authorities != null) {
56 Boolean authorityVal = null;
57 try {
58 authorityVal = authorities.getBoolean(requiredAuthority);
59 } catch (ClassCastException e) {
60 // JSON value for requiredAuthority is not a Boolean
61 }
62 // Return the authority value, or false if not present
63 handler.handle(Future.succeededFuture(authorityVal == null ? Boolean.FALSE : authorityVal));
64 return;
65 }
66
67 // Look up idToken in volatile map, to get userId (which is email address)
68 var userId = idTokenToUserId.get(idToken);
69
70 // If id_token is not in volatile map, look in persistent map, to save time compared to
71 // getting user id from server using OIDC protocol
72 var lookUpUserIdInPersistentMapFuture = Future.<String> future();
73 if (userId == null) {
74 // id token to user id map entry does not exist, look it up in MongoDB
75 mongoClient.find(USER_ID_COLLECTION_NAME, new JsonObject().put("_id", idToken), result -> {
76 if (result.succeeded() && result.result().size() == 1) {
77 var cachedUserId = result.result().get(0).getString("userId");
78 // Server must have restarted since user last logged in.
79 // Cache entry in volatile map, then complete the future.
80 idTokenToUserId.put(idToken, cachedUserId);
81 lookUpUserIdInPersistentMapFuture.complete(cachedUserId);
82 } else {
83 // User id was not found in persistent map -- need to look up user id from id token
84 lookUpUserIdInPersistentMapFuture.fail("id token not found");
85 }
86 });
87 } else {
88 // id token to user id map entry already exists, can complete future immediately
89 lookUpUserIdInPersistentMapFuture.complete(userId);
90 }
91
92 // If id_token was not in persistent map, get it from server using OIDC protocol
93 var lookupUserIdUsingOIDCFuture = Future.<String> future();
94 lookUpUserIdInPersistentMapFuture.setHandler((AsyncResult<String> ar) -> {
95 if (ar.succeeded()) {
96 // Previous step succeeded, do not need to call out to server
97 lookupUserIdUsingOIDCFuture.complete(ar.result());
98 } else {
99 // Request userInfo using OIDC protocol
100 user.userInfo(res -> {
101 if (res.failed()) {
102 // Request didn't succeed because the token was revoked
103 lookupUserIdUsingOIDCFuture.fail("Token revoked");
104 } else {
105 // TODO: get other user metadata
106 // The request succeeded -- we now have these fields from Google:
107 // {"sub":"#####################","name":"Luke Hutchison","given_name":"Luke",
108 // "family_name":"Hutchison","profile":"https://plus.google.com/+LukeHutchison",
109 // "picture":"https://lh4.googleusercontent.com/-V4v7MSgEKu4/AAAAAAAAAAI/AAAAAAABITY/hsbl0ZqG4qs/photo.jpg",
110 // "email":"luke.hutch@gmail.com","email_verified":true,"gender":"male","locale":"en"}
111 var userInfo = res.result();
112 var email = userInfo.getString("email");
113 if (email == null) {
114 lookupUserIdUsingOIDCFuture.fail("Did not get valid email address");
115 } else {
116 // Rename the "email" field to "_id"
117 userInfo.remove("email");
118 userInfo.put("_id", email);
119
120 // Check if there is a user entry in the database with this email address,
121 // and if not, create one. This requires the _id field to be unique and
122 // indexed (which it is by default), to allow atomic "put if absent" behavior.
123 // If record already exists with the same email address in _id, it will not
124 // be overridden (hence any added fields will be preserved).
125 mongoClient.insert(USER_INFO_COLLECTION_NAME, userInfo, ignored -> {
126 // Insert mapping from idToken to userId
127 // TODO: does every new OAuth2 session get a new user_id? If so, this table will grow without bound
128 mongoClient.save(USER_ID_COLLECTION_NAME,
129 new JsonObject().put("_id", idToken).put("userId", email),
130 ar2 -> {
131 if (ar2.failed()) {
132 logger.log(Level.SEVERE,
133 "Failed to save idToket to userId mapping for "
134 + email);
135 }
136 // Cache the idToken to userId mapping
137 idTokenToUserId.put(idToken, email);
138 // Whether failed or succeeded, complete the future.
139 lookupUserIdUsingOIDCFuture.complete(email);
140 });
141 });
142
143 }
144 }
145 });
146 }
147 });
148
149 var userInfoFuture = Future.<JsonObject> future();
150 lookupUserIdUsingOIDCFuture.setHandler(ar -> {
151 if (ar.succeeded()) {
152 // Get the user info for the user id
153 var email = lookupUserIdUsingOIDCFuture.result();
154 mongoClient.find(USER_INFO_COLLECTION_NAME, new JsonObject().put("_id", email),
155 asyncResult -> {
156 if (asyncResult.succeeded() && asyncResult.result().size() == 1) {
157 userInfoFuture.complete(asyncResult.result().get(0));
158 } else {
159 userInfoFuture.fail("userInfo record not found");
160 }
161 });
162 } else {
163 userInfoFuture.fail("userInfo record not found");
164 }
165 });
166
167 userInfoFuture.setHandler(ar -> {
168 if (ar.succeeded()) {
169 // Check if user record contains required authority object
170 // (i.e. 'authority { "$requiredAuthority": true }' )
171 var authoritiesFromUserInfo = ar.result().getJsonObject("authority");
172 if (authoritiesFromUserInfo != null) {
173 // Cache authorities
174 idTokenToAuthorities.put(idToken, authoritiesFromUserInfo);
175 // Read the named authority
176 Boolean authorityVal = null;
177 try {
178 authorityVal = authoritiesFromUserInfo.getBoolean(requiredAuthority);
179 } catch (ClassCastException e) {
180 // JSON value for requiredAuthority is not a Boolean
181 }
182 // Return the authority value, or false if not present
183 handler.handle(Future
184 .succeededFuture(authorityVal == null ? Boolean.FALSE : authorityVal));
185 return;
186 } else {
187 // There were no authorities -- cache an empty authority object
188 idTokenToAuthorities.put(idToken, new JsonObject());
189 }
190 // The authority value was not present -- return false
191 handler.handle(Future.succeededFuture(Boolean.FALSE));
192 } else {
193 handler.handle(Future.failedFuture("Could not read userInfo"));
194 }
195 });
196 }
197});
198router.route().handler(UserSessionHandler.create(googleAuthProvider));
199
200var callbackPath = "/oauth2/google/callback";
201var googleAuthHandler = OAuth2AuthHandler.create(googleAuthProvider,
202 "https://" + Verticle.serverProperties.host + callbackPath);
203googleAuthHandler
204 // Set up callback handler
205 .setupCallback(router.route(callbackPath))
206 // Add authorities (these three are the authorities provided by Google)
207 .addAuthority("email") //
208 .addAuthority("profile") //
209 .addAuthority("openid"); // TODO: is openid needed?
210router.route().handler(googleAuthHandler);