1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.auth.shiro;
18  
19  import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.throwResponse;
20  import static java.util.Objects.requireNonNull;
21  
22  import java.time.Duration;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.concurrent.CompletableFuture;
26  import java.util.function.Function;
27  
28  import javax.annotation.Nullable;
29  
30  import org.apache.shiro.authc.IncorrectCredentialsException;
31  import org.apache.shiro.authc.UnknownAccountException;
32  import org.apache.shiro.authc.UsernamePasswordToken;
33  import org.apache.shiro.mgt.SecurityManager;
34  import org.apache.shiro.subject.Subject;
35  import org.apache.shiro.subject.Subject.Builder;
36  import org.apache.shiro.util.ThreadContext;
37  import org.slf4j.Logger;
38  import org.slf4j.LoggerFactory;
39  
40  import com.fasterxml.jackson.core.JsonProcessingException;
41  
42  import com.linecorp.armeria.common.AggregatedHttpRequest;
43  import com.linecorp.armeria.common.HttpRequest;
44  import com.linecorp.armeria.common.HttpResponse;
45  import com.linecorp.armeria.common.HttpStatus;
46  import com.linecorp.armeria.common.MediaType;
47  import com.linecorp.armeria.common.RequestHeaders;
48  import com.linecorp.armeria.common.auth.BasicToken;
49  import com.linecorp.armeria.common.util.Exceptions;
50  import com.linecorp.armeria.server.AbstractHttpService;
51  import com.linecorp.armeria.server.ServiceRequestContext;
52  import com.linecorp.armeria.server.auth.AuthTokenExtractors;
53  import com.linecorp.centraldogma.internal.Jackson;
54  import com.linecorp.centraldogma.internal.api.v1.AccessToken;
55  import com.linecorp.centraldogma.server.auth.Session;
56  import com.linecorp.centraldogma.server.internal.api.HttpApiUtil;
57  
58  import io.netty.handler.codec.http.QueryStringDecoder;
59  
60  /**
61   * A service to handle a login request to Central Dogma Web admin service.
62   */
63  final class LoginService extends AbstractHttpService {
64      private static final Logger logger = LoggerFactory.getLogger(LoginService.class);
65  
66      private final SecurityManager securityManager;
67      private final Function<String, String> loginNameNormalizer;
68      private final Function<Session, CompletableFuture<Void>> loginSessionPropagator;
69      private final Duration sessionValidDuration;
70  
71      LoginService(SecurityManager securityManager,
72                   Function<String, String> loginNameNormalizer,
73                   Function<Session, CompletableFuture<Void>> loginSessionPropagator,
74                   Duration sessionValidDuration) {
75          this.securityManager = requireNonNull(securityManager, "securityManager");
76          this.loginNameNormalizer = requireNonNull(loginNameNormalizer, "loginNameNormalizer");
77          this.loginSessionPropagator = requireNonNull(loginSessionPropagator, "loginSessionPropagator");
78          this.sessionValidDuration = requireNonNull(sessionValidDuration, "sessionValidDuration");
79      }
80  
81      @Override
82      protected HttpResponse doPost(ServiceRequestContext ctx, HttpRequest req) throws Exception {
83          return HttpResponse.from(
84                  req.aggregate()
85                     .thenApply(msg -> usernamePassword(ctx, msg))
86                     .thenComposeAsync(usernamePassword -> {
87                         ThreadContext.bind(securityManager);
88                         Subject currentUser = null;
89                         try {
90                             currentUser = new Builder(securityManager).buildSubject();
91                             currentUser.login(usernamePassword);
92  
93                             final org.apache.shiro.session.Session session = currentUser.getSession(false);
94                             final String sessionId = session.getId().toString();
95                             final Session newSession =
96                                     new Session(sessionId, usernamePassword.getUsername(),
97                                                 sessionValidDuration);
98                             final Subject loginUser = currentUser;
99                             // loginSessionPropagator will propagate the authenticated session to all replicas
100                            // in the cluster.
101                            return loginSessionPropagator.apply(newSession).handle((unused, cause) -> {
102                                if (cause != null) {
103                                    ThreadContext.bind(securityManager);
104                                    logoutUserQuietly(ctx, loginUser);
105                                    ThreadContext.unbindSecurityManager();
106                                    return HttpApiUtil.newResponse(ctx, HttpStatus.INTERNAL_SERVER_ERROR,
107                                                                   Exceptions.peel(cause));
108                                }
109 
110                                logger.debug("{} Logged in: {} ({})",
111                                             ctx, usernamePassword.getUsername(), sessionId);
112 
113                                // expires_in means valid seconds of the token from the creation.
114                                final AccessToken accessToken =
115                                        new AccessToken(sessionId, sessionValidDuration.getSeconds());
116                                try {
117                                    return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8,
118                                                           Jackson.writeValueAsBytes(accessToken));
119                                } catch (JsonProcessingException e) {
120                                    return HttpApiUtil.newResponse(ctx, HttpStatus.INTERNAL_SERVER_ERROR, e);
121                                }
122                            });
123                        } catch (IncorrectCredentialsException e) {
124                            // Not authorized
125                            logger.debug("{} Incorrect password: {}", ctx, usernamePassword.getUsername());
126                            return CompletableFuture.completedFuture(
127                                    HttpApiUtil.newResponse(ctx, HttpStatus.UNAUTHORIZED, "Incorrect password"));
128                        } catch (UnknownAccountException e) {
129                            logger.debug("{} unknown account: {}", ctx, usernamePassword.getUsername());
130                            return CompletableFuture.completedFuture(
131                                    HttpApiUtil.newResponse(ctx, HttpStatus.UNAUTHORIZED, "unknown account"));
132                        } catch (Throwable t) {
133                            logger.warn("{} Failed to authenticate: {}", ctx, usernamePassword.getUsername(), t);
134                            return CompletableFuture.completedFuture(
135                                    HttpApiUtil.newResponse(ctx, HttpStatus.INTERNAL_SERVER_ERROR, t));
136                        } finally {
137                            logoutUserQuietly(ctx, currentUser);
138                            ThreadContext.unbindSecurityManager();
139                        }
140                    }, ctx.blockingTaskExecutor()));
141     }
142 
143     private static void logoutUserQuietly(ServiceRequestContext ctx, @Nullable Subject user) {
144         try {
145             if (user != null && !user.isAuthenticated()) {
146                 user.logout();
147             }
148         } catch (Exception cause) {
149             logger.debug("{} Failed to logout a user: {}", ctx, user, cause);
150         }
151     }
152 
153     /**
154      * Returns {@link UsernamePasswordToken} which holds a username and a password.
155      */
156     private UsernamePasswordToken usernamePassword(ServiceRequestContext ctx, AggregatedHttpRequest req) {
157         // check the Basic HTTP authentication first (https://tools.ietf.org/html/rfc7617)
158         final BasicToken basicToken = AuthTokenExtractors.basic().apply(RequestHeaders.of(req.headers()));
159         if (basicToken != null) {
160             return new UsernamePasswordToken(basicToken.username(), basicToken.password());
161         }
162 
163         final MediaType mediaType = req.headers().contentType();
164         if (mediaType != MediaType.FORM_DATA) {
165             return throwResponse(ctx, HttpStatus.BAD_REQUEST,
166                                  "The content type of a login request must be '%s'.", MediaType.FORM_DATA);
167         }
168 
169         final Map<String, List<String>> parameters = new QueryStringDecoder(req.contentUtf8(),
170                                                                             false).parameters();
171         // assume that the grant_type is "password"
172         final List<String> usernames = parameters.get("username");
173         final List<String> passwords = parameters.get("password");
174         if (usernames != null && passwords != null) {
175             final String username = usernames.get(0);
176             final String password = passwords.get(0);
177             return new UsernamePasswordToken(loginNameNormalizer.apply(username), password);
178         }
179 
180         return throwResponse(ctx, HttpStatus.BAD_REQUEST,
181                              "A login request must contain username and password.");
182     }
183 }