1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
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
100
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
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
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
155
156 private UsernamePasswordToken usernamePassword(ServiceRequestContext ctx, AggregatedHttpRequest req) {
157
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
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 }