1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 package com.linecorp.centraldogma.server.internal.admin.auth;
17
18 import static com.google.common.base.Preconditions.checkArgument;
19 import static java.util.Objects.requireNonNull;
20 import static org.quartz.CronScheduleBuilder.cronSchedule;
21 import static org.quartz.JobBuilder.newJob;
22 import static org.quartz.TriggerBuilder.newTrigger;
23 import static org.quartz.core.jmx.JobDataMapSupport.newJobDataMap;
24
25 import java.io.FileNotFoundException;
26 import java.io.IOException;
27 import java.nio.file.FileAlreadyExistsException;
28 import java.nio.file.Files;
29 import java.nio.file.NoSuchFileException;
30 import java.nio.file.Path;
31 import java.nio.file.StandardCopyOption;
32 import java.time.Instant;
33 import java.util.Objects;
34 import java.util.Properties;
35 import java.util.UUID;
36 import java.util.concurrent.CompletableFuture;
37 import java.util.regex.Pattern;
38 import java.util.stream.Stream;
39
40 import javax.annotation.Nullable;
41
42 import org.quartz.Job;
43 import org.quartz.JobDetail;
44 import org.quartz.JobExecutionContext;
45 import org.quartz.JobExecutionException;
46 import org.quartz.Scheduler;
47 import org.quartz.SchedulerException;
48 import org.quartz.Trigger;
49 import org.quartz.impl.StdSchedulerFactory;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 import com.google.common.collect.ImmutableMap;
54
55 import com.linecorp.centraldogma.internal.Jackson;
56 import com.linecorp.centraldogma.server.auth.AuthConfig;
57 import com.linecorp.centraldogma.server.auth.AuthException;
58 import com.linecorp.centraldogma.server.auth.Session;
59 import com.linecorp.centraldogma.server.auth.SessionManager;
60
61
62
63
64
65
66
67 public final class FileBasedSessionManager implements SessionManager {
68 private static final Logger logger = LoggerFactory.getLogger(FileBasedSessionManager.class);
69
70 private static final int SESSION_ID_LENGTH = nextSessionId().length();
71 private static final int SESSION_ID_1ST_PART_LENGTH = 2;
72 private static final Pattern SESSION_ID_1ST_PART_PATTERN = Pattern.compile(
73 "^[0-9a-f]{" + SESSION_ID_1ST_PART_LENGTH + "}$");
74 private static final int SESSION_ID_2ND_PART_LENGTH = SESSION_ID_LENGTH - SESSION_ID_1ST_PART_LENGTH;
75 private static final Pattern SESSION_ID_2ND_PART_PATTERN =
76 Pattern.compile("^[-0-9a-f]{" + SESSION_ID_2ND_PART_LENGTH + "}$");
77 private static final Pattern SESSION_ID_PATTERN =
78 Pattern.compile("^[-0-9a-f]{" + SESSION_ID_LENGTH + "}$");
79
80 private static final String ROOT_DIR = "ROOT_DIR";
81
82 private final Path rootDir;
83 private final Path tmpDir;
84
85 @Nullable
86 private final Scheduler scheduler;
87
88
89
90
91
92
93
94
95 public FileBasedSessionManager(
96 Path rootDir, @Nullable String cronExpr) throws IOException, SchedulerException {
97 this.rootDir = requireNonNull(rootDir, "rootDir");
98
99 tmpDir = rootDir.resolve("tmp");
100 Files.createDirectories(tmpDir);
101
102 if (cronExpr != null) {
103 scheduler = createScheduler(cronExpr);
104 scheduler.start();
105 } else {
106 scheduler = null;
107 }
108 }
109
110 private Scheduler createScheduler(String cronExpr) throws SchedulerException {
111
112
113
114
115 final String myInstanceId = String.valueOf(hashCode());
116
117 final Properties cfg = new Properties();
118 cfg.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore");
119 cfg.setProperty("org.quartz.scheduler.instanceName",
120 FileBasedSessionManager.class.getSimpleName() + '@' + myInstanceId);
121 cfg.setProperty("org.quartz.scheduler.skipUpdateCheck", "true");
122 cfg.setProperty("org.quartz.threadPool.threadCount", "1");
123
124 final Scheduler scheduler = new StdSchedulerFactory(cfg).getScheduler();
125
126 final JobDetail job = newJob(ExpiredSessionDeletingJob.class)
127 .usingJobData(newJobDataMap(ImmutableMap.of(ROOT_DIR, rootDir)))
128 .build();
129
130 final Trigger trigger = newTrigger()
131 .withIdentity(myInstanceId, ExpiredSessionDeletingJob.class.getSimpleName())
132 .withSchedule(cronSchedule(cronExpr))
133 .build();
134
135 scheduler.scheduleJob(job, trigger);
136 return scheduler;
137 }
138
139 @Override
140 public String generateSessionId() {
141 return nextSessionId();
142 }
143
144 private static String nextSessionId() {
145 return UUID.randomUUID().toString();
146 }
147
148 @Override
149 public CompletableFuture<Boolean> exists(String sessionId) {
150 requireNonNull(sessionId, "sessionId");
151 return CompletableFuture.completedFuture(isSessionFile(sessionId2PathOrNull(sessionId)));
152 }
153
154 @Override
155 public CompletableFuture<Session> get(String sessionId) {
156 requireNonNull(sessionId, "sessionId");
157
158 final Path path = sessionId2PathOrNull(sessionId);
159 if (!isSessionFile(path)) {
160 return CompletableFuture.completedFuture(null);
161 }
162 try {
163 return CompletableFuture.completedFuture(
164 Jackson.readValue(Files.readAllBytes(path), Session.class));
165 } catch (IOException e) {
166 return CompletableFuture.completedFuture(null);
167 }
168 }
169
170 @Override
171 public CompletableFuture<Void> create(Session session) {
172 requireNonNull(session, "session");
173 return CompletableFuture.supplyAsync(() -> {
174 final String sessionId = session.id();
175 final Path newPath = sessionId2Path(sessionId);
176 try {
177 try {
178
179 Files.createDirectories(newPath.getParent());
180 } catch (FileAlreadyExistsException e) {
181
182 throw new AuthException(e);
183 }
184
185 final Path tmpPath = Files.createTempFile(tmpDir, null, null);
186 Files.write(tmpPath, Jackson.writeValueAsBytes(session));
187 Files.move(tmpPath, newPath, StandardCopyOption.ATOMIC_MOVE);
188 return null;
189 } catch (FileAlreadyExistsException unused) {
190 throw new AuthException("duplicate session: " + sessionId);
191 } catch (IOException e) {
192 throw new AuthException(e);
193 }
194 });
195 }
196
197 @Override
198 public CompletableFuture<Void> update(Session session) {
199 requireNonNull(session, "session");
200 return CompletableFuture.supplyAsync(() -> {
201 final String sessionId = session.id();
202 final Path oldPath = sessionId2Path(sessionId);
203 if (!Files.exists(oldPath)) {
204 throw new AuthException("unknown session: " + sessionId);
205 }
206
207 try {
208 final Path newPath = Files.createTempFile(tmpDir, null, null);
209 Files.write(newPath, Jackson.writeValueAsBytes(session));
210 Files.move(newPath, oldPath,
211 StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
212 return null;
213 } catch (IOException e) {
214 throw new AuthException(e);
215 }
216 });
217 }
218
219 @Override
220 public CompletableFuture<Void> delete(String sessionId) {
221 requireNonNull(sessionId, "sessionId");
222 return CompletableFuture.supplyAsync(() -> {
223 try {
224 Files.deleteIfExists(sessionId2Path(sessionId));
225 } catch (IOException e) {
226 throw new AuthException(e);
227 }
228 return null;
229 });
230 }
231
232 @Override
233 public void close() throws Exception {
234 if (scheduler != null && !scheduler.isShutdown()) {
235
236
237 scheduler.shutdown(true);
238 }
239 }
240
241 private Path sessionId2Path(String sessionId) {
242 final Path path = sessionId2PathOrNull(sessionId);
243 checkArgument(path != null, "sessionId: %s (expected: UUID)", sessionId);
244 return path;
245 }
246
247 @Nullable
248 private Path sessionId2PathOrNull(String sessionId) {
249 return sessionId2PathOrNull(rootDir, sessionId);
250 }
251
252 @Nullable
253 private static Path sessionId2PathOrNull(Path rootDir, String sessionId) {
254 if (!SESSION_ID_PATTERN.matcher(sessionId).matches()) {
255 return null;
256 }
257 return rootDir.resolve(sessionId.substring(0, SESSION_ID_1ST_PART_LENGTH))
258 .resolve(sessionId.substring(SESSION_ID_1ST_PART_LENGTH));
259 }
260
261 private static boolean isSessionFile(@Nullable Path path) {
262 if (path == null) {
263 return false;
264 }
265 if (!Files.isRegularFile(path)) {
266 return false;
267 }
268 final int nameCount = path.getNameCount();
269 if (nameCount < 2) {
270 return false;
271 }
272
273 final String first = path.getName(nameCount - 2).toString();
274 if (!SESSION_ID_1ST_PART_PATTERN.matcher(first).matches()) {
275 return false;
276 }
277
278 final String second = path.getName(nameCount - 1).toString();
279 return SESSION_ID_2ND_PART_PATTERN.matcher(second).matches();
280 }
281
282
283
284
285 public static class ExpiredSessionDeletingJob implements Job {
286 @Override
287 public void execute(JobExecutionContext context) throws JobExecutionException {
288 try {
289 logger.debug("Started {} job.", ExpiredSessionDeletingJob.class.getSimpleName());
290 final Path rootDir = (Path) context.getJobDetail().getJobDataMap().get(ROOT_DIR);
291 final Instant now = Instant.now();
292 try (Stream<Path> stream = Files.walk(rootDir, 2)) {
293 stream.filter(FileBasedSessionManager::isSessionFile)
294 .map(path -> {
295 try {
296 return Jackson.readValue(Files.readAllBytes(path), Session.class);
297 } catch (FileNotFoundException | NoSuchFileException ignored) {
298
299 } catch (Exception e) {
300 logger.warn("Failed to deserialize a session: {}", path, e);
301 try {
302 Files.deleteIfExists(path);
303 logger.debug("Deleted an invalid session: {}", path);
304 } catch (IOException cause) {
305 logger.warn("Failed to delete an invalid session: {}", path, cause);
306 }
307 }
308 return null;
309 })
310 .filter(Objects::nonNull)
311 .filter(session -> now.isAfter(session.expirationTime()))
312 .forEach(session -> {
313 final Path path = sessionId2PathOrNull(rootDir, session.id());
314 if (path == null) {
315 return;
316 }
317 try {
318 Files.deleteIfExists(path);
319 logger.debug("Deleted an expired session: {}", path);
320 } catch (Throwable cause) {
321 logger.warn("Failed to delete an expired session: {}", path, cause);
322 }
323 });
324 }
325 logger.debug("Finished {} job.", ExpiredSessionDeletingJob.class.getSimpleName());
326 } catch (Throwable cause) {
327 logger.warn("Failed {} job:", ExpiredSessionDeletingJob.class.getSimpleName(), cause);
328 }
329 }
330 }
331 }