From e365537b7031c71fef821236b395fc7bb776f9f3 Mon Sep 17 00:00:00 2001 From: johnniang Date: Wed, 5 Feb 2020 18:08:29 +0800 Subject: [PATCH 1/3] Create temporary file after halo is started --- .../app/config/properties/HaloProperties.java | 6 ------ .../halo/app/listener/StartedListener.java | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/main/java/run/halo/app/config/properties/HaloProperties.java b/src/main/java/run/halo/app/config/properties/HaloProperties.java index f683761a4..59f68d322 100644 --- a/src/main/java/run/halo/app/config/properties/HaloProperties.java +++ b/src/main/java/run/halo/app/config/properties/HaloProperties.java @@ -68,10 +68,4 @@ public class HaloProperties { */ private String cache = "memory"; - - public HaloProperties() throws IOException { - // Create work directory if not exist - Files.createDirectories(Paths.get(workDir)); - Files.createDirectories(Paths.get(backupDir)); - } } diff --git a/src/main/java/run/halo/app/listener/StartedListener.java b/src/main/java/run/halo/app/listener/StartedListener.java index 882463485..0ba175e7f 100644 --- a/src/main/java/run/halo/app/listener/StartedListener.java +++ b/src/main/java/run/halo/app/listener/StartedListener.java @@ -57,6 +57,7 @@ public class StartedListener implements ApplicationListener Date: Tue, 11 Feb 2020 19:57:07 +0800 Subject: [PATCH 2/3] Remove temp token mechanism --- .../admin/api/BackupController.java | 1 + .../filter/AbstractAuthenticationFilter.java | 49 ++----------- .../halo/app/security/util/SecurityUtils.java | 9 --- .../app/service/impl/BackupServiceImpl.java | 70 +++++++------------ 4 files changed, 32 insertions(+), 97 deletions(-) diff --git a/src/main/java/run/halo/app/controller/admin/api/BackupController.java b/src/main/java/run/halo/app/controller/admin/api/BackupController.java index e75d7f4b0..b250f2da0 100644 --- a/src/main/java/run/halo/app/controller/admin/api/BackupController.java +++ b/src/main/java/run/halo/app/controller/admin/api/BackupController.java @@ -68,6 +68,7 @@ public class BackupController { contentType = request.getServletContext().getMimeType(backupResource.getFile().getAbsolutePath()); } catch (IOException e) { log.warn("Could not determine file type", e); + // Ignore this error } return ResponseEntity.ok() diff --git a/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java b/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java index 04c16a954..2423858fb 100644 --- a/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java +++ b/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java @@ -9,14 +9,11 @@ import org.springframework.util.Assert; import org.springframework.web.filter.OncePerRequestFilter; import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; -import run.halo.app.exception.ForbiddenException; import run.halo.app.exception.NotInstallException; import run.halo.app.model.properties.PrimaryProperties; -import run.halo.app.model.support.HaloConst; import run.halo.app.security.context.SecurityContextHolder; import run.halo.app.security.handler.AuthenticationFailureHandler; import run.halo.app.security.handler.DefaultAuthenticationFailureHandler; -import run.halo.app.security.util.SecurityUtils; import run.halo.app.service.OptionService; import javax.servlet.FilterChain; @@ -24,7 +21,10 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; /** * Abstract authentication filter. @@ -157,11 +157,6 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter return; } - if (checkForTempToken(request)) { - filterChain.doFilter(request, response); - return; - } - try { // Do authenticate doAuthenticate(request, response, filterChain); @@ -170,41 +165,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter } } - private boolean checkForTempToken(HttpServletRequest request) { - // Get token from request - String tempToken = getTokenFromRequest(request, HaloConst.TEMP_TOKEN, HaloConst.TEMP_TOKEN); - - if (StringUtils.isEmpty(tempToken)) { - return false; - } - - String tempTokenKey = SecurityUtils.buildTempTokenKey(tempToken); - // Check the token - Optional tokenCountOptional = cacheStore.getAny(tempTokenKey, Integer.class); - - if (!tokenCountOptional.isPresent()) { - // If the token is not found - throw new ForbiddenException("The temporary token has been expired").setErrorData(tempToken); - } - - log.info("Got valid temp token: [{}]", tempToken); - - int count = tokenCountOptional.get(); - // TODO May cause unsafe thread, fixing next time - // Count down - count--; - if (count <= 0) { - // If count is less than 0, then clear this temp token - cacheStore.delete(tempTokenKey); - } else { - // Put the less count - cacheStore.put(tempTokenKey, String.valueOf(count)); - } - - return true; - } - - String getTokenFromRequest(@NonNull HttpServletRequest request, @NonNull String tokenQueryName, @NonNull String tokenHeaderName) { + protected String getTokenFromRequest(@NonNull HttpServletRequest request, @NonNull String tokenQueryName, @NonNull String tokenHeaderName) { Assert.notNull(request, "Http servlet request must not be null"); Assert.hasText(tokenQueryName, "Token query name must not be blank"); Assert.hasText(tokenHeaderName, "Token header name must not be blank"); diff --git a/src/main/java/run/halo/app/security/util/SecurityUtils.java b/src/main/java/run/halo/app/security/util/SecurityUtils.java index 7fb1b54d5..a12d9c981 100644 --- a/src/main/java/run/halo/app/security/util/SecurityUtils.java +++ b/src/main/java/run/halo/app/security/util/SecurityUtils.java @@ -26,9 +26,6 @@ public class SecurityUtils { private final static String REFRESH_TOKEN_CACHE_PREFIX = "halo.admin.refresh_token."; - private final static String TEMP_TOKEN_CACHE_PREFIX = "halo.temp.token."; - - private SecurityUtils() { } @@ -60,10 +57,4 @@ public class SecurityUtils { return TOKEN_REFRESH_CACHE_PREFIX + refreshToken; } - @NonNull - public static String buildTempTokenKey(@NonNull String tempToken) { - Assert.hasText(tempToken, "Temporary token must not be blank"); - - return TEMP_TOKEN_CACHE_PREFIX + tempToken; - } } diff --git a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java index d99aa92a5..c88733553 100644 --- a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java @@ -6,7 +6,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.DateFormatUtils; -import org.apache.http.client.utils.URIBuilder; import org.json.JSONObject; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -24,7 +23,6 @@ import run.halo.app.model.dto.post.BasePostDetailDTO; import run.halo.app.model.entity.Post; import run.halo.app.model.entity.Tag; import run.halo.app.model.support.HaloConst; -import run.halo.app.security.util.SecurityUtils; import run.halo.app.service.BackupService; import run.halo.app.service.OptionService; import run.halo.app.service.PostService; @@ -34,7 +32,6 @@ import run.halo.app.utils.HaloUtils; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; @@ -42,17 +39,10 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; -import static run.halo.app.model.support.HaloConst.TEMP_TOKEN; -import static run.halo.app.model.support.HaloConst.TEMP_TOKEN_EXPIRATION; - /** * Backup service implementation. * @@ -191,8 +181,14 @@ public class BackupServiceImpl implements BackupService { @Override public List listHaloBackups() { + // Ensure the parent folder exist + Path backupParentPath = Paths.get(haloProperties.getBackupDir()); + if (Files.notExists(backupParentPath)) { + return Collections.emptyList(); + } + // Build backup dto - try (Stream subPathStream = Files.list(Paths.get(haloProperties.getBackupDir()))) { + try (Stream subPathStream = Files.list(backupParentPath)) { return subPathStream .filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_PREFIX)) .map(this::buildBackupDto) @@ -236,19 +232,32 @@ public class BackupServiceImpl implements BackupService { public Resource loadFileAsResource(String fileName) { Assert.hasText(fileName, "Backup file name must not be blank"); - // Get backup file path - Path backupFilePath = Paths.get(haloProperties.getBackupDir(), fileName).normalize(); + Path backupParentPath = Paths.get(haloProperties.getBackupDir()); + try { + if (Files.notExists(backupParentPath)) { + // Create backup parent path if it does not exists + Files.createDirectories(backupParentPath); + } + + // Get backup file path + Path backupFilePath = Paths.get(haloProperties.getBackupDir(), fileName).normalize(); + + // Check directory traversal + run.halo.app.utils.FileUtils.checkDirectoryTraversal(backupParentPath, backupFilePath); + // Build url resource Resource backupResource = new UrlResource(backupFilePath.toUri()); if (!backupResource.exists()) { - // If the backup resouce is not exist + // If the backup resource is not exist throw new NotFoundException("The file " + fileName + " was not found"); } // Return the backup resource return backupResource; } catch (MalformedURLException e) { throw new NotFoundException("The file " + fileName + " was not found", e); + } catch (IOException e) { + throw new ServiceException("Failed to create backup parent path: " + backupParentPath, e); } } @@ -271,8 +280,6 @@ public class BackupServiceImpl implements BackupService { backup.setFileSize(Files.size(backupPath)); } catch (IOException e) { throw new ServiceException("Failed to access file " + backupPath, e); - } catch (URISyntaxException e) { - throw new ServiceException("Failed to generate download link for file: " + backupPath, e); } return backup; @@ -284,36 +291,11 @@ public class BackupServiceImpl implements BackupService { * @param filename filename must not be blank * @return download url */ - private String buildDownloadUrl(@NonNull String filename) throws URISyntaxException { + private String buildDownloadUrl(@NonNull String filename) { Assert.hasText(filename, "File name must not be blank"); // Composite http url - String backupFullUrl = HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), "api/admin/backups/halo", filename); - - // Get temp token - String tempToken = cacheStore.get(buildBackupTokenKey(filename)).orElseGet(() -> { - String token = buildTempToken(1); - // Cache this projection - cacheStore.putIfAbsent(buildBackupTokenKey(filename), token, TEMP_TOKEN_EXPIRATION.toDays(), TimeUnit.DAYS); - return token; - }); - - return new URIBuilder(backupFullUrl).addParameter(TEMP_TOKEN, tempToken).toString(); + return HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), "api/admin/backups/halo", filename); } - private String buildBackupTokenKey(String backupFileName) { - return BACKUP_TOKEN_KEY_PREFIX + backupFileName; - } - - private String buildTempToken(@NonNull Object value) { - Assert.notNull(value, "Temp token value must not be null"); - - // Generate temp token - String tempToken = HaloUtils.randomUUIDWithoutDash(); - - // Cache the token - cacheStore.putIfAbsent(SecurityUtils.buildTempTokenKey(tempToken), value.toString(), TEMP_TOKEN_EXPIRATION.toDays(), TimeUnit.DAYS); - - return tempToken; - } } From 118aaf8ddc5b86119f841880f5f39d719343ddf9 Mon Sep 17 00:00:00 2001 From: johnniang Date: Wed, 12 Feb 2020 21:10:00 +0800 Subject: [PATCH 3/3] Create one-time token mechanism --- .../halo/app/config/HaloConfiguration.java | 25 ++++-- .../java/run/halo/app/filter/CorsFilter.java | 5 +- .../java/run/halo/app/model/enums/Mode.java | 13 +-- .../run/halo/app/model/support/HaloConst.java | 7 +- .../filter/AbstractAuthenticationFilter.java | 86 ++++++++++++++++--- .../filter/AdminAuthenticationFilter.java | 15 ++-- .../filter/ApiAuthenticationFilter.java | 20 ++--- .../app/security/filter/ContentFilter.java | 8 +- .../security/service/OneTimeTokenService.java | 38 ++++++++ .../service/impl/OneTimeTokenServiceImpl.java | 60 +++++++++++++ .../app/service/impl/BackupServiceImpl.java | 34 ++++++-- src/main/resources/application-test.yaml | 3 +- .../halo/app/security/OneTimeTokenTest.java | 51 +++++++++++ 13 files changed, 304 insertions(+), 61 deletions(-) create mode 100644 src/main/java/run/halo/app/security/service/OneTimeTokenService.java create mode 100644 src/main/java/run/halo/app/security/service/impl/OneTimeTokenServiceImpl.java create mode 100644 src/test/java/run/halo/app/security/OneTimeTokenTest.java diff --git a/src/main/java/run/halo/app/config/HaloConfiguration.java b/src/main/java/run/halo/app/config/HaloConfiguration.java index eb67f0bf2..5cc5b1bed 100644 --- a/src/main/java/run/halo/app/config/HaloConfiguration.java +++ b/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -3,6 +3,7 @@ package run.halo.app.config; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.client.RestTemplateBuilder; @@ -19,11 +20,13 @@ import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; import run.halo.app.filter.CorsFilter; import run.halo.app.filter.LogFilter; +import run.halo.app.model.enums.Mode; import run.halo.app.security.filter.AdminAuthenticationFilter; import run.halo.app.security.filter.ApiAuthenticationFilter; import run.halo.app.security.filter.ContentFilter; import run.halo.app.security.handler.ContentAuthenticationFailureHandler; import run.halo.app.security.handler.DefaultAuthenticationFailureHandler; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.OptionService; import run.halo.app.service.UserService; import run.halo.app.utils.HaloUtils; @@ -116,8 +119,10 @@ public class HaloConfiguration { @Bean public FilterRegistrationBean contentFilter(HaloProperties haloProperties, OptionService optionService, - StringCacheStore cacheStore) { - ContentFilter contentFilter = new ContentFilter(haloProperties, optionService, cacheStore); + StringCacheStore cacheStore, + OneTimeTokenService oneTimeTokenService, + @Value("${spring.profiles.active:prod}") String activeProfile) { + ContentFilter contentFilter = new ContentFilter(haloProperties, optionService, cacheStore, oneTimeTokenService, Mode.valueFrom(activeProfile)); contentFilter.setFailureHandler(new ContentAuthenticationFailureHandler()); String adminPattern = HaloUtils.ensureBoth(haloProperties.getAdminPath(), "/") + "**"; @@ -142,8 +147,10 @@ public class HaloConfiguration { public FilterRegistrationBean apiAuthenticationFilter(HaloProperties haloProperties, ObjectMapper objectMapper, OptionService optionService, - StringCacheStore cacheStore) { - ApiAuthenticationFilter apiFilter = new ApiAuthenticationFilter(haloProperties, optionService, cacheStore); + StringCacheStore cacheStore, + OneTimeTokenService oneTimeTokenService, + @Value("${spring.profiles.active:prod}") String activeProfile) { + ApiAuthenticationFilter apiFilter = new ApiAuthenticationFilter(haloProperties, optionService, cacheStore, oneTimeTokenService, Mode.valueFrom(activeProfile)); apiFilter.addExcludeUrlPatterns( "/api/content/*/comments", "/api/content/**/comments/**", @@ -170,8 +177,11 @@ public class HaloConfiguration { UserService userService, HaloProperties haloProperties, ObjectMapper objectMapper, - OptionService optionService) { - AdminAuthenticationFilter adminAuthenticationFilter = new AdminAuthenticationFilter(cacheStore, userService, haloProperties, optionService); + OptionService optionService, + OneTimeTokenService oneTimeTokenService, + @Value("${spring.profiles.active:prod}") String activeProfile) { + AdminAuthenticationFilter adminAuthenticationFilter = new AdminAuthenticationFilter(cacheStore, userService, + haloProperties, optionService, oneTimeTokenService, Mode.valueFrom(activeProfile)); DefaultAuthenticationFailureHandler failureHandler = new DefaultAuthenticationFailureHandler(); failureHandler.setProductionEnv(haloProperties.isProductionEnv()); @@ -188,8 +198,7 @@ public class HaloConfiguration { "/api/admin/password/code", "/api/admin/password/reset" ); - adminAuthenticationFilter.setFailureHandler( - failureHandler); + adminAuthenticationFilter.setFailureHandler(failureHandler); FilterRegistrationBean authenticationFilter = new FilterRegistrationBean<>(); authenticationFilter.setFilter(adminAuthenticationFilter); diff --git a/src/main/java/run/halo/app/filter/CorsFilter.java b/src/main/java/run/halo/app/filter/CorsFilter.java index 51d653acc..77279f2fd 100644 --- a/src/main/java/run/halo/app/filter/CorsFilter.java +++ b/src/main/java/run/halo/app/filter/CorsFilter.java @@ -31,7 +31,10 @@ public class CorsFilter extends GenericFilterBean { HttpServletResponse httpServletResponse = (HttpServletResponse) response; // Set customized header - httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, httpServletRequest.getHeader(HttpHeaders.ORIGIN)); + String originHeaderValue = httpServletRequest.getHeader(HttpHeaders.ORIGIN); + if (StringUtils.isNotBlank(originHeaderValue)) { + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, originHeaderValue); + } httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, ALLOW_HEADERS); httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); diff --git a/src/main/java/run/halo/app/model/enums/Mode.java b/src/main/java/run/halo/app/model/enums/Mode.java index f9c8e32b8..6dfc2dcab 100644 --- a/src/main/java/run/halo/app/model/enums/Mode.java +++ b/src/main/java/run/halo/app/model/enums/Mode.java @@ -20,24 +20,19 @@ public enum Mode { * Get mode from value. * * @param value mode value - * @return runtime mode or null if the value is mismatch + * @return runtime mode */ - @Nullable @JsonCreator public static Mode valueFrom(@Nullable String value) { - if (StringUtils.isBlank(value) || "prod".equalsIgnoreCase(value)) { - return Mode.PRODUCTION; - } - - if ("dev".equalsIgnoreCase(value)) { + if (StringUtils.equalsIgnoreCase("dev", value)) { return Mode.DEVELOPMENT; } - if ("test".equalsIgnoreCase(value)) { + if (StringUtils.equalsIgnoreCase("test", value)) { return Mode.TEST; } - return null; + return PRODUCTION; } @JsonValue diff --git a/src/main/java/run/halo/app/model/support/HaloConst.java b/src/main/java/run/halo/app/model/support/HaloConst.java index d8f45327b..8a989afa1 100644 --- a/src/main/java/run/halo/app/model/support/HaloConst.java +++ b/src/main/java/run/halo/app/model/support/HaloConst.java @@ -125,7 +125,12 @@ public class HaloConst { * Content api token param name */ public final static String API_ACCESS_KEY_QUERY_NAME = "api_access_key"; - public final static Duration TEMP_TOKEN_EXPIRATION = Duration.ofDays(7); + + public final static String ONE_TIME_TOKEN_QUERY_NAME = "ott"; + + public final static String ONE_TIME_TOKEN_HEADER_NAME = "ott"; + + /** * user_session */ diff --git a/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java b/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java index 2423858fb..1a2e5048d 100644 --- a/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java +++ b/src/main/java/run/halo/app/security/filter/AbstractAuthenticationFilter.java @@ -9,11 +9,16 @@ import org.springframework.util.Assert; import org.springframework.web.filter.OncePerRequestFilter; import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; +import run.halo.app.exception.BadRequestException; +import run.halo.app.exception.ForbiddenException; +import run.halo.app.exception.HaloException; import run.halo.app.exception.NotInstallException; +import run.halo.app.model.enums.Mode; import run.halo.app.model.properties.PrimaryProperties; import run.halo.app.security.context.SecurityContextHolder; import run.halo.app.security.handler.AuthenticationFailureHandler; import run.halo.app.security.handler.DefaultAuthenticationFailureHandler; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.OptionService; import javax.servlet.FilterChain; @@ -26,6 +31,9 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +import static run.halo.app.model.support.HaloConst.ONE_TIME_TOKEN_HEADER_NAME; +import static run.halo.app.model.support.HaloConst.ONE_TIME_TOKEN_QUERY_NAME; + /** * Abstract authentication filter. * @@ -43,18 +51,26 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter protected final StringCacheStore cacheStore; - private AuthenticationFailureHandler failureHandler; + private OneTimeTokenService oneTimeTokenService; + + private final Mode mode; + + private volatile AuthenticationFailureHandler failureHandler; /** * Exclude url patterns. */ - private Set excludeUrlPatterns = new HashSet<>(2); + private Set excludeUrlPatterns = new HashSet<>(16); AbstractAuthenticationFilter(HaloProperties haloProperties, OptionService optionService, - StringCacheStore cacheStore) { + StringCacheStore cacheStore, + OneTimeTokenService oneTimeTokenService, + Mode mode) { this.haloProperties = haloProperties; this.optionService = optionService; this.cacheStore = cacheStore; + this.oneTimeTokenService = oneTimeTokenService; + this.mode = mode; antPathMatcher = new AntPathMatcher(); } @@ -74,7 +90,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter protected boolean shouldNotFilter(HttpServletRequest request) { Assert.notNull(request, "Http servlet request must not be null"); - return excludeUrlPatterns.stream().anyMatch(p -> antPathMatcher.match(p, request.getServletPath())); + return excludeUrlPatterns.stream().anyMatch(p -> antPathMatcher.match(p, request.getRequestURI())); } /** @@ -115,7 +131,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter * @return authentication failure handler */ @NonNull - protected AuthenticationFailureHandler getFailureHandler() { + private AuthenticationFailureHandler getFailureHandler() { if (failureHandler == null) { synchronized (this) { if (failureHandler == null) { @@ -135,7 +151,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter * * @param failureHandler authentication failure handler */ - public void setFailureHandler(@NonNull AuthenticationFailureHandler failureHandler) { + public synchronized void setFailureHandler(@NonNull AuthenticationFailureHandler failureHandler) { Assert.notNull(failureHandler, "Authentication failure handler must not be null"); this.failureHandler = failureHandler; @@ -146,25 +162,70 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter // Check whether the blog is installed or not Boolean isInstalled = optionService.getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false); - if (!isInstalled) { + if (!isInstalled && mode != Mode.TEST) { // If not installed getFailureHandler().onFailure(request, response, new NotInstallException("当前博客还没有初始化")); return; } - if (shouldNotFilter(request)) { - filterChain.doFilter(request, response); - return; - } - try { + // Check the one-time-token + if (isSufficientOneTimeToken(request)) { + filterChain.doFilter(request, response); + return; + } + // Do authenticate doAuthenticate(request, response, filterChain); + } catch (HaloException e) { + getFailureHandler().onFailure(request, response, e); } finally { SecurityContextHolder.clearContext(); } } + /** + * Check if the sufficient one-time token is set. + * + * @param request http servlet request + * @return true if sufficient; false otherwise + */ + private boolean isSufficientOneTimeToken(HttpServletRequest request) { + // Check the param + final String oneTimeToken = getTokenFromRequest(request, ONE_TIME_TOKEN_QUERY_NAME, ONE_TIME_TOKEN_HEADER_NAME); + + if (StringUtils.isBlank(oneTimeToken)) { + // If no one-time token is not provided, skip + return false; + } + + // Get allowed uri + String allowedUri = oneTimeTokenService.get(oneTimeToken) + .orElseThrow(() -> new BadRequestException("The one-time token does not exist").setErrorData(oneTimeToken)); + + // Get request uri + String requestUri = request.getRequestURI(); + + if (!StringUtils.equals(requestUri, allowedUri)) { + // If the request uri mismatches the allowed uri + // TODO using ant path matcher could be better + throw new ForbiddenException("The one-time token does not correspond the request uri").setErrorData(oneTimeToken); + } + + // Revoke the token before return + oneTimeTokenService.revoke(oneTimeToken); + + return true; + } + + /** + * Get token from http servlet request. + * + * @param request http servlet request must not be null + * @param tokenQueryName token query name must not be blank + * @param tokenHeaderName token header name must not be blank + * @return corresponding token + */ protected String getTokenFromRequest(@NonNull HttpServletRequest request, @NonNull String tokenQueryName, @NonNull String tokenHeaderName) { Assert.notNull(request, "Http servlet request must not be null"); Assert.hasText(tokenQueryName, "Token query name must not be blank"); @@ -176,7 +237,6 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter // Get from param if (StringUtils.isBlank(accessKey)) { accessKey = request.getParameter(tokenQueryName); - log.debug("Got access key from parameter: [{}: {}]", tokenQueryName, accessKey); } else { log.debug("Got access key from header: [{}: {}]", tokenHeaderName, accessKey); diff --git a/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java b/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java index 143da643d..8d45befe9 100644 --- a/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java +++ b/src/main/java/run/halo/app/security/filter/AdminAuthenticationFilter.java @@ -7,9 +7,11 @@ import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; import run.halo.app.exception.AuthenticationException; import run.halo.app.model.entity.User; +import run.halo.app.model.enums.Mode; import run.halo.app.security.authentication.AuthenticationImpl; import run.halo.app.security.context.SecurityContextHolder; import run.halo.app.security.context.SecurityContextImpl; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.security.support.UserDetail; import run.halo.app.security.util.SecurityUtils; import run.halo.app.service.OptionService; @@ -35,14 +37,15 @@ public class AdminAuthenticationFilter extends AbstractAuthenticationFilter { private final HaloProperties haloProperties; - private final UserService userService; public AdminAuthenticationFilter(StringCacheStore cacheStore, UserService userService, HaloProperties haloProperties, - OptionService optionService) { - super(haloProperties, optionService, cacheStore); + OptionService optionService, + OneTimeTokenService oneTimeTokenService, + Mode mode) { + super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode); this.userService = userService; this.haloProperties = haloProperties; } @@ -64,16 +67,14 @@ public class AdminAuthenticationFilter extends AbstractAuthenticationFilter { String token = getTokenFromRequest(request); if (StringUtils.isBlank(token)) { - getFailureHandler().onFailure(request, response, new AuthenticationException("未登录,请登陆后访问")); - return; + throw new AuthenticationException("未登录,请登陆后访问"); } // Get user id from cache Optional optionalUserId = cacheStore.getAny(SecurityUtils.buildTokenAccessKey(token), Integer.class); if (!optionalUserId.isPresent()) { - getFailureHandler().onFailure(request, response, new AuthenticationException("Token 已过期或不存在").setErrorData(token)); - return; + throw new AuthenticationException("Token 已过期或不存在").setErrorData(token); } // Get the user diff --git a/src/main/java/run/halo/app/security/filter/ApiAuthenticationFilter.java b/src/main/java/run/halo/app/security/filter/ApiAuthenticationFilter.java index 8c4fe35b1..5ebe5f1c6 100644 --- a/src/main/java/run/halo/app/security/filter/ApiAuthenticationFilter.java +++ b/src/main/java/run/halo/app/security/filter/ApiAuthenticationFilter.java @@ -7,8 +7,10 @@ import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; import run.halo.app.exception.AuthenticationException; import run.halo.app.exception.ForbiddenException; +import run.halo.app.model.enums.Mode; import run.halo.app.model.properties.ApiProperties; import run.halo.app.model.properties.CommentProperties; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.OptionService; import javax.servlet.FilterChain; @@ -33,8 +35,10 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter { public ApiAuthenticationFilter(HaloProperties haloProperties, OptionService optionService, - StringCacheStore cacheStore) { - super(haloProperties, optionService, cacheStore); + StringCacheStore cacheStore, + OneTimeTokenService oneTimeTokenService, + Mode mode) { + super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode); this.optionService = optionService; } @@ -50,8 +54,7 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter { Boolean apiEnabled = optionService.getByPropertyOrDefault(ApiProperties.API_ENABLED, Boolean.class, false); if (!apiEnabled) { - getFailureHandler().onFailure(request, response, new ForbiddenException("API has been disabled by blogger currently")); - return; + throw new ForbiddenException("API has been disabled by blogger currently"); } // Get access key @@ -59,8 +62,7 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter { if (StringUtils.isBlank(accessKey)) { // If the access key is missing - getFailureHandler().onFailure(request, response, new AuthenticationException("Missing API access key")); - return; + throw new AuthenticationException("Missing API access key"); } // Get access key from option @@ -68,14 +70,12 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter { if (!optionalAccessKey.isPresent()) { // If the access key is not set - getFailureHandler().onFailure(request, response, new AuthenticationException("API access key hasn't been set by blogger")); - return; + throw new AuthenticationException("API access key hasn't been set by blogger"); } if (!StringUtils.equals(accessKey, optionalAccessKey.get())) { // If the access key is mismatch - getFailureHandler().onFailure(request, response, new AuthenticationException("API access key is mismatch")); - return; + throw new AuthenticationException("API access key is mismatch").setErrorData(accessKey); } // Do filter diff --git a/src/main/java/run/halo/app/security/filter/ContentFilter.java b/src/main/java/run/halo/app/security/filter/ContentFilter.java index 530941472..cce447f3d 100644 --- a/src/main/java/run/halo/app/security/filter/ContentFilter.java +++ b/src/main/java/run/halo/app/security/filter/ContentFilter.java @@ -2,6 +2,8 @@ package run.halo.app.security.filter; import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; +import run.halo.app.model.enums.Mode; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.OptionService; import javax.servlet.FilterChain; @@ -20,8 +22,10 @@ public class ContentFilter extends AbstractAuthenticationFilter { public ContentFilter(HaloProperties haloProperties, OptionService optionService, - StringCacheStore cacheStore) { - super(haloProperties, optionService, cacheStore); + StringCacheStore cacheStore, + OneTimeTokenService oneTimeTokenService, + Mode mode) { + super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode); } @Override diff --git a/src/main/java/run/halo/app/security/service/OneTimeTokenService.java b/src/main/java/run/halo/app/security/service/OneTimeTokenService.java new file mode 100644 index 000000000..1b25dce0e --- /dev/null +++ b/src/main/java/run/halo/app/security/service/OneTimeTokenService.java @@ -0,0 +1,38 @@ +package run.halo.app.security.service; + +import org.springframework.lang.NonNull; + +import java.util.Optional; + +/** + * One-time-token service interface. + * + * @author johnniang + */ +public interface OneTimeTokenService { + + /** + * Get the corresponding uri. + * + * @param oneTimeToken one-time token must not be null + * @return the corresponding uri + */ + @NonNull + Optional get(@NonNull String oneTimeToken); + + /** + * Create one time token. + * + * @param uri request uri. + * @return one time token. + */ + @NonNull + String create(@NonNull String uri); + + /** + * Revoke one time token. + * + * @param oneTimeToken one time token must not be null + */ + void revoke(@NonNull String oneTimeToken); +} diff --git a/src/main/java/run/halo/app/security/service/impl/OneTimeTokenServiceImpl.java b/src/main/java/run/halo/app/security/service/impl/OneTimeTokenServiceImpl.java new file mode 100644 index 000000000..125f1d800 --- /dev/null +++ b/src/main/java/run/halo/app/security/service/impl/OneTimeTokenServiceImpl.java @@ -0,0 +1,60 @@ +package run.halo.app.security.service.impl; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import run.halo.app.cache.StringCacheStore; +import run.halo.app.security.service.OneTimeTokenService; +import run.halo.app.utils.HaloUtils; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * One-time token service implementation. + * + * @author johnniang + */ +@Service +public class OneTimeTokenServiceImpl implements OneTimeTokenService { + + /** + * One-time token expired day. (unit: day) + */ + public static final int OTT_EXPIRED_DAY = 1; + + private final StringCacheStore cacheStore; + + public OneTimeTokenServiceImpl(StringCacheStore cacheStore) { + this.cacheStore = cacheStore; + } + + @Override + public Optional get(String oneTimeToken) { + Assert.hasText(oneTimeToken, "One-time token must not be blank"); + + // Get from cache store + return cacheStore.get(oneTimeToken); + } + + @Override + public String create(String uri) { + Assert.hasText(uri, "Request uri must not be blank"); + + // Generate ott + String oneTimeToken = HaloUtils.randomUUIDWithoutDash(); + + // Put ott along with request uri + cacheStore.put(oneTimeToken, uri, OTT_EXPIRED_DAY, TimeUnit.DAYS); + + // Return ott + return oneTimeToken; + } + + @Override + public void revoke(String oneTimeToken) { + Assert.hasText(oneTimeToken, "One-time token must not be blank"); + + // Delete the token + cacheStore.delete(oneTimeToken); + } +} diff --git a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java index c88733553..4be161a85 100644 --- a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java @@ -14,7 +14,6 @@ import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.web.multipart.MultipartFile; import org.yaml.snakeyaml.Yaml; -import run.halo.app.cache.StringCacheStore; import run.halo.app.config.properties.HaloProperties; import run.halo.app.exception.NotFoundException; import run.halo.app.exception.ServiceException; @@ -23,6 +22,7 @@ import run.halo.app.model.dto.post.BasePostDetailDTO; import run.halo.app.model.entity.Post; import run.halo.app.model.entity.Tag; import run.halo.app.model.support.HaloConst; +import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.BackupService; import run.halo.app.service.OptionService; import run.halo.app.service.PostService; @@ -54,34 +54,40 @@ import java.util.stream.Stream; @Slf4j public class BackupServiceImpl implements BackupService { - public static final String BACKUP_TOKEN_KEY_PREFIX = "backup-token-"; + private static final String BACKUP_RESOURCE_BASE_URI = "/api/admin/backups/halo"; + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + private final PostService postService; + private final PostTagService postTagService; + private final OptionService optionService; - private final StringCacheStore cacheStore; + + private final OneTimeTokenService oneTimeTokenService; + private final HaloProperties haloProperties; public BackupServiceImpl(PostService postService, PostTagService postTagService, OptionService optionService, - StringCacheStore stringCacheStore, + OneTimeTokenService oneTimeTokenService, HaloProperties haloProperties) { this.postService = postService; this.postTagService = postTagService; this.optionService = optionService; - this.cacheStore = stringCacheStore; + this.oneTimeTokenService = oneTimeTokenService; this.haloProperties = haloProperties; } /** * Sanitizes the specified file name. * - * @param unsanitized the specified file name + * @param unSanitized the specified file name * @return sanitized file name */ - public static String sanitizeFilename(final String unsanitized) { - return unsanitized. + public static String sanitizeFilename(final String unSanitized) { + return unSanitized. replaceAll("[^(a-zA-Z0-9\\u4e00-\\u9fa5\\.)]", ""). replaceAll("[\\?\\\\/:|<>\\*\\[\\]\\(\\)\\$%\\{\\}@~\\.]", ""). replaceAll("\\s", ""); @@ -291,11 +297,21 @@ public class BackupServiceImpl implements BackupService { * @param filename filename must not be blank * @return download url */ + @NonNull private String buildDownloadUrl(@NonNull String filename) { Assert.hasText(filename, "File name must not be blank"); // Composite http url - return HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), "api/admin/backups/halo", filename); + String backupUri = BACKUP_RESOURCE_BASE_URI + HaloUtils.URL_SEPARATOR + filename; + + // Get a one-time token + String oneTimeToken = oneTimeTokenService.create(backupUri); + + // Build full url + return HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), backupUri) + + "?" + + HaloConst.ONE_TIME_TOKEN_QUERY_NAME + + "=" + oneTimeToken; } } diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml index dabe475b4..8bffad038 100755 --- a/src/main/resources/application-test.yaml +++ b/src/main/resources/application-test.yaml @@ -1,5 +1,5 @@ server: - port: 8090 + port: 18090 forward-headers-strategy: native compression: enabled: false @@ -59,4 +59,5 @@ logging: halo: doc-disabled: false + auth-enable: false workDir: ${user.home}/halo-test/ diff --git a/src/test/java/run/halo/app/security/OneTimeTokenTest.java b/src/test/java/run/halo/app/security/OneTimeTokenTest.java new file mode 100644 index 000000000..12bc7b54e --- /dev/null +++ b/src/test/java/run/halo/app/security/OneTimeTokenTest.java @@ -0,0 +1,51 @@ +package run.halo.app.security; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import run.halo.app.security.service.OneTimeTokenService; + +import static org.hamcrest.core.Is.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("test") +@AutoConfigureMockMvc +class OneTimeTokenTest { + + @Autowired + MockMvc mvc; + + @Autowired + OneTimeTokenService oneTimeTokenService; + + static final String REQUEST_URI = "/api/admin/counts"; + + @Test + void provideNonExistOneTimeTokenTest() throws Exception { + mvc.perform(get(REQUEST_URI + "?ott={ott}", "one-time-token-value")) + .andDo(print()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status", is(HttpStatus.BAD_REQUEST.value()))); + } + + @Test + void insertAnOneTimeTokenTest() throws Exception { + // Create ott + String ott = oneTimeTokenService.create(REQUEST_URI); + + mvc.perform(get(REQUEST_URI + "?ott={ott}", ott)) + .andDo(print()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } +}