Line data Source code
1 : // Copyright (C) 2015 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.httpd;
16 :
17 : import static com.google.gerrit.httpd.ProjectBasicAuthFilter.authenticationFailedMsg;
18 : import static java.nio.charset.StandardCharsets.UTF_8;
19 : import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
20 :
21 : import com.google.common.base.MoreObjects;
22 : import com.google.common.base.Strings;
23 : import com.google.common.collect.Iterables;
24 : import com.google.common.flogger.FluentLogger;
25 : import com.google.common.io.BaseEncoding;
26 : import com.google.gerrit.common.Nullable;
27 : import com.google.gerrit.entities.Account;
28 : import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
29 : import com.google.gerrit.extensions.registration.DynamicItem;
30 : import com.google.gerrit.extensions.registration.DynamicMap;
31 : import com.google.gerrit.extensions.registration.Extension;
32 : import com.google.gerrit.server.AccessPath;
33 : import com.google.gerrit.server.account.AccountCache;
34 : import com.google.gerrit.server.account.AccountException;
35 : import com.google.gerrit.server.account.AccountManager;
36 : import com.google.gerrit.server.account.AccountState;
37 : import com.google.gerrit.server.account.AuthRequest;
38 : import com.google.gerrit.server.account.AuthResult;
39 : import com.google.gerrit.server.config.GerritServerConfig;
40 : import com.google.inject.Inject;
41 : import com.google.inject.Singleton;
42 : import java.io.IOException;
43 : import java.io.UnsupportedEncodingException;
44 : import java.net.URLDecoder;
45 : import java.util.Locale;
46 : import java.util.NoSuchElementException;
47 : import java.util.Optional;
48 : import javax.servlet.Filter;
49 : import javax.servlet.FilterChain;
50 : import javax.servlet.FilterConfig;
51 : import javax.servlet.ServletException;
52 : import javax.servlet.ServletRequest;
53 : import javax.servlet.ServletResponse;
54 : import javax.servlet.http.Cookie;
55 : import javax.servlet.http.HttpServletRequest;
56 : import javax.servlet.http.HttpServletResponse;
57 : import javax.servlet.http.HttpServletResponseWrapper;
58 : import org.eclipse.jgit.lib.Config;
59 :
60 : /**
61 : * Authenticates the current user with an OAuth2 server.
62 : *
63 : * @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
64 : */
65 : @Singleton
66 : class ProjectOAuthFilter implements Filter {
67 0 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
68 :
69 : private static final String REALM_NAME = "Gerrit Code Review";
70 : private static final String AUTHORIZATION = "Authorization";
71 : private static final String BASIC = "Basic ";
72 : private static final String GIT_COOKIE_PREFIX = "git-";
73 :
74 : private final DynamicItem<WebSession> session;
75 : private final DynamicMap<OAuthLoginProvider> loginProviders;
76 : private final AccountCache accountCache;
77 : private final AccountManager accountManager;
78 : private final String gitOAuthProvider;
79 : private final boolean userNameToLowerCase;
80 : private final AuthRequest.Factory authRequestFactory;
81 :
82 : private String defaultAuthPlugin;
83 : private String defaultAuthProvider;
84 :
85 : @Inject
86 : ProjectOAuthFilter(
87 : DynamicItem<WebSession> session,
88 : DynamicMap<OAuthLoginProvider> pluginsProvider,
89 : AccountCache accountCache,
90 : AccountManager accountManager,
91 : @GerritServerConfig Config gerritConfig,
92 0 : AuthRequest.Factory authRequestFactory) {
93 0 : this.session = session;
94 0 : this.loginProviders = pluginsProvider;
95 0 : this.accountCache = accountCache;
96 0 : this.accountManager = accountManager;
97 0 : this.gitOAuthProvider = gerritConfig.getString("auth", null, "gitOAuthProvider");
98 0 : this.userNameToLowerCase = gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
99 0 : this.authRequestFactory = authRequestFactory;
100 0 : }
101 :
102 : @Override
103 : public void init(FilterConfig config) throws ServletException {
104 0 : if (Strings.isNullOrEmpty(gitOAuthProvider)) {
105 0 : pickOnlyProvider();
106 : } else {
107 0 : pickConfiguredProvider();
108 : }
109 0 : }
110 :
111 : @Override
112 0 : public void destroy() {}
113 :
114 : @Override
115 : public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
116 : throws IOException, ServletException {
117 0 : HttpServletRequest req = (HttpServletRequest) request;
118 0 : Response rsp = new Response((HttpServletResponse) response);
119 0 : if (verify(req, rsp)) {
120 0 : chain.doFilter(req, rsp);
121 : }
122 0 : }
123 :
124 : private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
125 : AuthInfo authInfo;
126 :
127 : // first check if there is a BASIC authentication header
128 0 : String hdr = req.getHeader(AUTHORIZATION);
129 0 : if (hdr != null && hdr.startsWith(BASIC)) {
130 0 : authInfo = extractAuthInfo(hdr, encoding(req));
131 0 : if (authInfo == null) {
132 0 : rsp.sendError(SC_UNAUTHORIZED);
133 0 : return false;
134 : }
135 : } else {
136 : // if there is no BASIC authentication header, check if there is
137 : // a cookie starting with the prefix "git-"
138 0 : Cookie cookie = findGitCookie(req);
139 0 : if (cookie != null) {
140 0 : authInfo = extractAuthInfo(cookie);
141 0 : if (authInfo == null) {
142 0 : rsp.sendError(SC_UNAUTHORIZED);
143 0 : return false;
144 : }
145 : } else {
146 : // if there is no authentication information at all, it might be
147 : // an anonymous connection, or there might be a session cookie
148 0 : return true;
149 : }
150 : }
151 :
152 : // if there is authentication information but no secret => 401
153 0 : if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
154 0 : rsp.sendError(SC_UNAUTHORIZED);
155 0 : return false;
156 : }
157 :
158 0 : Optional<AccountState> who =
159 0 : accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive());
160 0 : if (!who.isPresent()) {
161 0 : logger.atWarning().log(
162 : "%s: account inactive or not provisioned in Gerrit",
163 0 : authenticationFailedMsg(authInfo.username, req));
164 0 : rsp.sendError(SC_UNAUTHORIZED);
165 0 : return false;
166 : }
167 :
168 0 : Account account = who.get().account();
169 0 : AuthRequest authRequest = authRequestFactory.createForExternalUser(authInfo.username);
170 0 : authRequest.setEmailAddress(account.preferredEmail());
171 0 : authRequest.setDisplayName(account.fullName());
172 0 : authRequest.setPassword(authInfo.tokenOrSecret);
173 0 : authRequest.setAuthPlugin(authInfo.pluginName);
174 0 : authRequest.setAuthProvider(authInfo.exportName);
175 :
176 : try {
177 0 : AuthResult authResult = accountManager.authenticate(authRequest);
178 0 : WebSession ws = session.get();
179 0 : ws.setUserAccountId(authResult.getAccountId());
180 0 : ws.setAccessPathOk(AccessPath.GIT, true);
181 0 : ws.setAccessPathOk(AccessPath.REST_API, true);
182 0 : return true;
183 0 : } catch (AccountException e) {
184 0 : logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(authInfo.username, req));
185 0 : rsp.sendError(SC_UNAUTHORIZED);
186 0 : return false;
187 : }
188 : }
189 :
190 : /**
191 : * Picks the only installed OAuth provider. If there is a multiude of providers available, the
192 : * actual provider must be determined from the authentication request.
193 : *
194 : * @throws ServletException if there is no {@code OAuthLoginProvider} installed at all.
195 : */
196 : private void pickOnlyProvider() throws ServletException {
197 : try {
198 0 : Extension<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
199 0 : defaultAuthPlugin = loginProvider.getPluginName();
200 0 : defaultAuthProvider = loginProvider.getExportName();
201 0 : } catch (NoSuchElementException e) {
202 0 : throw new ServletException("No OAuth login provider installed", e);
203 0 : } catch (IllegalArgumentException e) {
204 : // multiple providers found => do not pick any
205 0 : }
206 0 : }
207 :
208 : /**
209 : * Picks the {@code OAuthLoginProvider} configured with <tt>auth.gitOAuthProvider</tt>.
210 : *
211 : * @throws ServletException if the configured provider was not found.
212 : */
213 : private void pickConfiguredProvider() throws ServletException {
214 0 : int splitPos = gitOAuthProvider.lastIndexOf(':');
215 0 : if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
216 : // no colon at all or leading/trailing colon: malformed providerId
217 0 : throw new ServletException(
218 : "OAuth login provider configuration is"
219 : + " invalid: Must be of the form pluginName:providerName");
220 : }
221 0 : defaultAuthPlugin = gitOAuthProvider.substring(0, splitPos);
222 0 : defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
223 0 : OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin, defaultAuthProvider);
224 0 : if (provider == null) {
225 0 : throw new ServletException(
226 : "Configured OAuth login provider " + gitOAuthProvider + " wasn't installed");
227 : }
228 0 : }
229 :
230 : @Nullable
231 : private AuthInfo extractAuthInfo(String hdr, String encoding)
232 : throws UnsupportedEncodingException {
233 0 : byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
234 0 : String usernamePassword = new String(decoded, encoding);
235 0 : int splitPos = usernamePassword.indexOf(':');
236 0 : if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
237 0 : return null;
238 : }
239 0 : return new AuthInfo(
240 0 : usernamePassword.substring(0, splitPos),
241 0 : usernamePassword.substring(splitPos + 1),
242 : defaultAuthPlugin,
243 : defaultAuthProvider);
244 : }
245 :
246 : @Nullable
247 : private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
248 0 : String username =
249 0 : URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
250 0 : String value = cookie.getValue();
251 0 : int splitPos = value.lastIndexOf('@');
252 0 : if (splitPos < 1 || splitPos == value.length() - 1) {
253 : // no providerId in the cookie value => assume default provider
254 : // note: a leading/trailing at sign is considered to belong to
255 : // the access token rather than being a separator
256 0 : return new AuthInfo(username, cookie.getValue(), defaultAuthPlugin, defaultAuthProvider);
257 : }
258 0 : String token = value.substring(0, splitPos);
259 0 : String providerId = value.substring(splitPos + 1);
260 0 : splitPos = providerId.lastIndexOf(':');
261 0 : if (splitPos < 1 || splitPos == providerId.length() - 1) {
262 : // no colon at all or leading/trailing colon: malformed providerId
263 0 : return null;
264 : }
265 0 : String pluginName = providerId.substring(0, splitPos);
266 0 : String exportName = providerId.substring(splitPos + 1);
267 0 : OAuthLoginProvider provider = loginProviders.get(pluginName, exportName);
268 0 : if (provider == null) {
269 0 : return null;
270 : }
271 0 : return new AuthInfo(username, token, pluginName, exportName);
272 : }
273 :
274 : private static String encoding(HttpServletRequest req) {
275 0 : return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
276 : }
277 :
278 : @Nullable
279 : private static Cookie findGitCookie(HttpServletRequest req) {
280 0 : Cookie[] cookies = req.getCookies();
281 0 : if (cookies != null) {
282 0 : for (Cookie cookie : cookies) {
283 0 : if (cookie.getName().startsWith(GIT_COOKIE_PREFIX)) {
284 0 : return cookie;
285 : }
286 : }
287 : }
288 0 : return null;
289 : }
290 :
291 : private class AuthInfo {
292 : private final String username;
293 : private final String tokenOrSecret;
294 : private final String pluginName;
295 : private final String exportName;
296 :
297 0 : private AuthInfo(String username, String tokenOrSecret, String pluginName, String exportName) {
298 0 : this.username = userNameToLowerCase ? username.toLowerCase(Locale.US) : username;
299 0 : this.tokenOrSecret = tokenOrSecret;
300 0 : this.pluginName = pluginName;
301 0 : this.exportName = exportName;
302 0 : }
303 : }
304 :
305 : private static class Response extends HttpServletResponseWrapper {
306 : private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
307 :
308 : Response(HttpServletResponse rsp) {
309 0 : super(rsp);
310 0 : }
311 :
312 : private void status(int sc) {
313 0 : if (sc == SC_UNAUTHORIZED) {
314 0 : StringBuilder v = new StringBuilder();
315 0 : v.append(BASIC);
316 0 : v.append("realm=\"").append(REALM_NAME).append("\"");
317 0 : setHeader(WWW_AUTHENTICATE, v.toString());
318 0 : } else if (containsHeader(WWW_AUTHENTICATE)) {
319 0 : setHeader(WWW_AUTHENTICATE, null);
320 : }
321 0 : }
322 :
323 : @Override
324 : public void sendError(int sc, String msg) throws IOException {
325 0 : status(sc);
326 0 : super.sendError(sc, msg);
327 0 : }
328 :
329 : @Override
330 : public void sendError(int sc) throws IOException {
331 0 : status(sc);
332 0 : super.sendError(sc);
333 0 : }
334 :
335 : @Override
336 : @Deprecated
337 : public void setStatus(int sc, String sm) {
338 0 : status(sc);
339 0 : super.setStatus(sc, sm);
340 0 : }
341 :
342 : @Override
343 : public void setStatus(int sc) {
344 0 : status(sc);
345 0 : super.setStatus(sc);
346 0 : }
347 : }
348 : }
|