Merge pull request #565 from JohnNiang/fix-backup-function

Fix backup function and create one-time token mechanism
pull/571/head
John Niang 2020-02-12 21:45:45 +08:00 committed by GitHub
commit f35648ac75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 342 additions and 149 deletions

View File

@ -3,6 +3,7 @@ package run.halo.app.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; 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.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; 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.config.properties.HaloProperties;
import run.halo.app.filter.CorsFilter; import run.halo.app.filter.CorsFilter;
import run.halo.app.filter.LogFilter; 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.AdminAuthenticationFilter;
import run.halo.app.security.filter.ApiAuthenticationFilter; import run.halo.app.security.filter.ApiAuthenticationFilter;
import run.halo.app.security.filter.ContentFilter; import run.halo.app.security.filter.ContentFilter;
import run.halo.app.security.handler.ContentAuthenticationFailureHandler; import run.halo.app.security.handler.ContentAuthenticationFailureHandler;
import run.halo.app.security.handler.DefaultAuthenticationFailureHandler; 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.OptionService;
import run.halo.app.service.UserService; import run.halo.app.service.UserService;
import run.halo.app.utils.HaloUtils; import run.halo.app.utils.HaloUtils;
@ -116,8 +119,10 @@ public class HaloConfiguration {
@Bean @Bean
public FilterRegistrationBean<ContentFilter> contentFilter(HaloProperties haloProperties, public FilterRegistrationBean<ContentFilter> contentFilter(HaloProperties haloProperties,
OptionService optionService, OptionService optionService,
StringCacheStore cacheStore) { StringCacheStore cacheStore,
ContentFilter contentFilter = new ContentFilter(haloProperties, optionService, 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()); contentFilter.setFailureHandler(new ContentAuthenticationFailureHandler());
String adminPattern = HaloUtils.ensureBoth(haloProperties.getAdminPath(), "/") + "**"; String adminPattern = HaloUtils.ensureBoth(haloProperties.getAdminPath(), "/") + "**";
@ -142,8 +147,10 @@ public class HaloConfiguration {
public FilterRegistrationBean<ApiAuthenticationFilter> apiAuthenticationFilter(HaloProperties haloProperties, public FilterRegistrationBean<ApiAuthenticationFilter> apiAuthenticationFilter(HaloProperties haloProperties,
ObjectMapper objectMapper, ObjectMapper objectMapper,
OptionService optionService, OptionService optionService,
StringCacheStore cacheStore) { StringCacheStore cacheStore,
ApiAuthenticationFilter apiFilter = new ApiAuthenticationFilter(haloProperties, optionService, cacheStore); OneTimeTokenService oneTimeTokenService,
@Value("${spring.profiles.active:prod}") String activeProfile) {
ApiAuthenticationFilter apiFilter = new ApiAuthenticationFilter(haloProperties, optionService, cacheStore, oneTimeTokenService, Mode.valueFrom(activeProfile));
apiFilter.addExcludeUrlPatterns( apiFilter.addExcludeUrlPatterns(
"/api/content/*/comments", "/api/content/*/comments",
"/api/content/**/comments/**", "/api/content/**/comments/**",
@ -170,8 +177,11 @@ public class HaloConfiguration {
UserService userService, UserService userService,
HaloProperties haloProperties, HaloProperties haloProperties,
ObjectMapper objectMapper, ObjectMapper objectMapper,
OptionService optionService) { OptionService optionService,
AdminAuthenticationFilter adminAuthenticationFilter = new AdminAuthenticationFilter(cacheStore, userService, haloProperties, 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(); DefaultAuthenticationFailureHandler failureHandler = new DefaultAuthenticationFailureHandler();
failureHandler.setProductionEnv(haloProperties.isProductionEnv()); failureHandler.setProductionEnv(haloProperties.isProductionEnv());
@ -188,8 +198,7 @@ public class HaloConfiguration {
"/api/admin/password/code", "/api/admin/password/code",
"/api/admin/password/reset" "/api/admin/password/reset"
); );
adminAuthenticationFilter.setFailureHandler( adminAuthenticationFilter.setFailureHandler(failureHandler);
failureHandler);
FilterRegistrationBean<AdminAuthenticationFilter> authenticationFilter = new FilterRegistrationBean<>(); FilterRegistrationBean<AdminAuthenticationFilter> authenticationFilter = new FilterRegistrationBean<>();
authenticationFilter.setFilter(adminAuthenticationFilter); authenticationFilter.setFilter(adminAuthenticationFilter);

View File

@ -68,10 +68,4 @@ public class HaloProperties {
*/ */
private String cache = "memory"; private String cache = "memory";
public HaloProperties() throws IOException {
// Create work directory if not exist
Files.createDirectories(Paths.get(workDir));
Files.createDirectories(Paths.get(backupDir));
}
} }

View File

@ -68,6 +68,7 @@ public class BackupController {
contentType = request.getServletContext().getMimeType(backupResource.getFile().getAbsolutePath()); contentType = request.getServletContext().getMimeType(backupResource.getFile().getAbsolutePath());
} catch (IOException e) { } catch (IOException e) {
log.warn("Could not determine file type", e); log.warn("Could not determine file type", e);
// Ignore this error
} }
return ResponseEntity.ok() return ResponseEntity.ok()

View File

@ -31,7 +31,10 @@ public class CorsFilter extends GenericFilterBean {
HttpServletResponse httpServletResponse = (HttpServletResponse) response; HttpServletResponse httpServletResponse = (HttpServletResponse) response;
// Set customized header // 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_HEADERS, ALLOW_HEADERS);
httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");

View File

@ -57,6 +57,7 @@ public class StartedListener implements ApplicationListener<ApplicationStartedEv
public void onApplicationEvent(ApplicationStartedEvent event) { public void onApplicationEvent(ApplicationStartedEvent event) {
this.migrate(); this.migrate();
this.initThemes(); this.initThemes();
this.initDirectory();
this.printStartInfo(); this.printStartInfo();
} }
@ -140,4 +141,24 @@ public class StartedListener implements ApplicationListener<ApplicationStartedEv
return fileSystem; return fileSystem;
} }
private void initDirectory() {
Path workPath = Paths.get(haloProperties.getWorkDir());
Path backupPath = Paths.get(haloProperties.getBackupDir());
try {
if (Files.notExists(workPath)) {
Files.createDirectories(workPath);
log.info("Created work directory: [{}]", workPath);
}
if (Files.notExists(backupPath)) {
Files.createDirectories(backupPath);
log.info("Created backup directory: [{}]", backupPath);
}
} catch (IOException ie) {
throw new RuntimeException("Failed to initialize directories", ie);
}
}
} }

View File

@ -20,24 +20,19 @@ public enum Mode {
* Get mode from value. * Get mode from value.
* *
* @param value mode value * @param value mode value
* @return runtime mode or null if the value is mismatch * @return runtime mode
*/ */
@Nullable
@JsonCreator @JsonCreator
public static Mode valueFrom(@Nullable String value) { public static Mode valueFrom(@Nullable String value) {
if (StringUtils.isBlank(value) || "prod".equalsIgnoreCase(value)) { if (StringUtils.equalsIgnoreCase("dev", value)) {
return Mode.PRODUCTION;
}
if ("dev".equalsIgnoreCase(value)) {
return Mode.DEVELOPMENT; return Mode.DEVELOPMENT;
} }
if ("test".equalsIgnoreCase(value)) { if (StringUtils.equalsIgnoreCase("test", value)) {
return Mode.TEST; return Mode.TEST;
} }
return null; return PRODUCTION;
} }
@JsonValue @JsonValue

View File

@ -125,7 +125,12 @@ public class HaloConst {
* Content api token param name * Content api token param name
*/ */
public final static String API_ACCESS_KEY_QUERY_NAME = "api_access_key"; 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 * user_session
*/ */

View File

@ -9,14 +9,16 @@ import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import run.halo.app.cache.StringCacheStore; import run.halo.app.cache.StringCacheStore;
import run.halo.app.config.properties.HaloProperties; import run.halo.app.config.properties.HaloProperties;
import run.halo.app.exception.BadRequestException;
import run.halo.app.exception.ForbiddenException; import run.halo.app.exception.ForbiddenException;
import run.halo.app.exception.HaloException;
import run.halo.app.exception.NotInstallException; import run.halo.app.exception.NotInstallException;
import run.halo.app.model.enums.Mode;
import run.halo.app.model.properties.PrimaryProperties; 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.context.SecurityContextHolder;
import run.halo.app.security.handler.AuthenticationFailureHandler; import run.halo.app.security.handler.AuthenticationFailureHandler;
import run.halo.app.security.handler.DefaultAuthenticationFailureHandler; import run.halo.app.security.handler.DefaultAuthenticationFailureHandler;
import run.halo.app.security.util.SecurityUtils; import run.halo.app.security.service.OneTimeTokenService;
import run.halo.app.service.OptionService; import run.halo.app.service.OptionService;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
@ -24,7 +26,13 @@ import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.Collection;
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. * Abstract authentication filter.
@ -43,18 +51,26 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter
protected final StringCacheStore cacheStore; protected final StringCacheStore cacheStore;
private AuthenticationFailureHandler failureHandler; private OneTimeTokenService oneTimeTokenService;
private final Mode mode;
private volatile AuthenticationFailureHandler failureHandler;
/** /**
* Exclude url patterns. * Exclude url patterns.
*/ */
private Set<String> excludeUrlPatterns = new HashSet<>(2); private Set<String> excludeUrlPatterns = new HashSet<>(16);
AbstractAuthenticationFilter(HaloProperties haloProperties, AbstractAuthenticationFilter(HaloProperties haloProperties,
OptionService optionService, OptionService optionService,
StringCacheStore cacheStore) { StringCacheStore cacheStore,
OneTimeTokenService oneTimeTokenService,
Mode mode) {
this.haloProperties = haloProperties; this.haloProperties = haloProperties;
this.optionService = optionService; this.optionService = optionService;
this.cacheStore = cacheStore; this.cacheStore = cacheStore;
this.oneTimeTokenService = oneTimeTokenService;
this.mode = mode;
antPathMatcher = new AntPathMatcher(); antPathMatcher = new AntPathMatcher();
} }
@ -74,7 +90,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
Assert.notNull(request, "Http servlet request must not be null"); 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 * @return authentication failure handler
*/ */
@NonNull @NonNull
protected AuthenticationFailureHandler getFailureHandler() { private AuthenticationFailureHandler getFailureHandler() {
if (failureHandler == null) { if (failureHandler == null) {
synchronized (this) { synchronized (this) {
if (failureHandler == null) { if (failureHandler == null) {
@ -135,7 +151,7 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter
* *
* @param failureHandler authentication failure handler * @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"); Assert.notNull(failureHandler, "Authentication failure handler must not be null");
this.failureHandler = failureHandler; this.failureHandler = failureHandler;
@ -146,65 +162,71 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter
// Check whether the blog is installed or not // Check whether the blog is installed or not
Boolean isInstalled = optionService.getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false); Boolean isInstalled = optionService.getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);
if (!isInstalled) { if (!isInstalled && mode != Mode.TEST) {
// If not installed // If not installed
getFailureHandler().onFailure(request, response, new NotInstallException("当前博客还没有初始化")); getFailureHandler().onFailure(request, response, new NotInstallException("当前博客还没有初始化"));
return; return;
} }
if (shouldNotFilter(request)) {
filterChain.doFilter(request, response);
return;
}
if (checkForTempToken(request)) {
filterChain.doFilter(request, response);
return;
}
try { try {
// Check the one-time-token
if (isSufficientOneTimeToken(request)) {
filterChain.doFilter(request, response);
return;
}
// Do authenticate // Do authenticate
doAuthenticate(request, response, filterChain); doAuthenticate(request, response, filterChain);
} catch (HaloException e) {
getFailureHandler().onFailure(request, response, e);
} finally { } finally {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
} }
} }
private boolean checkForTempToken(HttpServletRequest request) { /**
// Get token from request * Check if the sufficient one-time token is set.
String tempToken = getTokenFromRequest(request, HaloConst.TEMP_TOKEN, HaloConst.TEMP_TOKEN); *
* @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.isEmpty(tempToken)) { if (StringUtils.isBlank(oneTimeToken)) {
// If no one-time token is not provided, skip
return false; return false;
} }
String tempTokenKey = SecurityUtils.buildTempTokenKey(tempToken); // Get allowed uri
// Check the token String allowedUri = oneTimeTokenService.get(oneTimeToken)
Optional<Integer> tokenCountOptional = cacheStore.getAny(tempTokenKey, Integer.class); .orElseThrow(() -> new BadRequestException("The one-time token does not exist").setErrorData(oneTimeToken));
if (!tokenCountOptional.isPresent()) { // Get request uri
// If the token is not found String requestUri = request.getRequestURI();
throw new ForbiddenException("The temporary token has been expired").setErrorData(tempToken);
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);
} }
log.info("Got valid temp token: [{}]", tempToken); // Revoke the token before return
oneTimeTokenService.revoke(oneTimeToken);
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; return true;
} }
String getTokenFromRequest(@NonNull HttpServletRequest request, @NonNull String tokenQueryName, @NonNull String tokenHeaderName) { /**
* 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.notNull(request, "Http servlet request must not be null");
Assert.hasText(tokenQueryName, "Token query name must not be blank"); Assert.hasText(tokenQueryName, "Token query name must not be blank");
Assert.hasText(tokenHeaderName, "Token header name must not be blank"); Assert.hasText(tokenHeaderName, "Token header name must not be blank");
@ -215,7 +237,6 @@ public abstract class AbstractAuthenticationFilter extends OncePerRequestFilter
// Get from param // Get from param
if (StringUtils.isBlank(accessKey)) { if (StringUtils.isBlank(accessKey)) {
accessKey = request.getParameter(tokenQueryName); accessKey = request.getParameter(tokenQueryName);
log.debug("Got access key from parameter: [{}: {}]", tokenQueryName, accessKey); log.debug("Got access key from parameter: [{}: {}]", tokenQueryName, accessKey);
} else { } else {
log.debug("Got access key from header: [{}: {}]", tokenHeaderName, accessKey); log.debug("Got access key from header: [{}: {}]", tokenHeaderName, accessKey);

View File

@ -7,9 +7,11 @@ import run.halo.app.cache.StringCacheStore;
import run.halo.app.config.properties.HaloProperties; import run.halo.app.config.properties.HaloProperties;
import run.halo.app.exception.AuthenticationException; import run.halo.app.exception.AuthenticationException;
import run.halo.app.model.entity.User; 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.authentication.AuthenticationImpl;
import run.halo.app.security.context.SecurityContextHolder; import run.halo.app.security.context.SecurityContextHolder;
import run.halo.app.security.context.SecurityContextImpl; 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.support.UserDetail;
import run.halo.app.security.util.SecurityUtils; import run.halo.app.security.util.SecurityUtils;
import run.halo.app.service.OptionService; import run.halo.app.service.OptionService;
@ -35,14 +37,15 @@ public class AdminAuthenticationFilter extends AbstractAuthenticationFilter {
private final HaloProperties haloProperties; private final HaloProperties haloProperties;
private final UserService userService; private final UserService userService;
public AdminAuthenticationFilter(StringCacheStore cacheStore, public AdminAuthenticationFilter(StringCacheStore cacheStore,
UserService userService, UserService userService,
HaloProperties haloProperties, HaloProperties haloProperties,
OptionService optionService) { OptionService optionService,
super(haloProperties, optionService, cacheStore); OneTimeTokenService oneTimeTokenService,
Mode mode) {
super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode);
this.userService = userService; this.userService = userService;
this.haloProperties = haloProperties; this.haloProperties = haloProperties;
} }
@ -64,16 +67,14 @@ public class AdminAuthenticationFilter extends AbstractAuthenticationFilter {
String token = getTokenFromRequest(request); String token = getTokenFromRequest(request);
if (StringUtils.isBlank(token)) { if (StringUtils.isBlank(token)) {
getFailureHandler().onFailure(request, response, new AuthenticationException("未登录,请登陆后访问")); throw new AuthenticationException("未登录,请登陆后访问");
return;
} }
// Get user id from cache // Get user id from cache
Optional<Integer> optionalUserId = cacheStore.getAny(SecurityUtils.buildTokenAccessKey(token), Integer.class); Optional<Integer> optionalUserId = cacheStore.getAny(SecurityUtils.buildTokenAccessKey(token), Integer.class);
if (!optionalUserId.isPresent()) { if (!optionalUserId.isPresent()) {
getFailureHandler().onFailure(request, response, new AuthenticationException("Token 已过期或不存在").setErrorData(token)); throw new AuthenticationException("Token 已过期或不存在").setErrorData(token);
return;
} }
// Get the user // Get the user

View File

@ -7,8 +7,10 @@ import run.halo.app.cache.StringCacheStore;
import run.halo.app.config.properties.HaloProperties; import run.halo.app.config.properties.HaloProperties;
import run.halo.app.exception.AuthenticationException; import run.halo.app.exception.AuthenticationException;
import run.halo.app.exception.ForbiddenException; 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.ApiProperties;
import run.halo.app.model.properties.CommentProperties; import run.halo.app.model.properties.CommentProperties;
import run.halo.app.security.service.OneTimeTokenService;
import run.halo.app.service.OptionService; import run.halo.app.service.OptionService;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
@ -33,8 +35,10 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter {
public ApiAuthenticationFilter(HaloProperties haloProperties, public ApiAuthenticationFilter(HaloProperties haloProperties,
OptionService optionService, OptionService optionService,
StringCacheStore cacheStore) { StringCacheStore cacheStore,
super(haloProperties, optionService, cacheStore); OneTimeTokenService oneTimeTokenService,
Mode mode) {
super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode);
this.optionService = optionService; this.optionService = optionService;
} }
@ -50,8 +54,7 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter {
Boolean apiEnabled = optionService.getByPropertyOrDefault(ApiProperties.API_ENABLED, Boolean.class, false); Boolean apiEnabled = optionService.getByPropertyOrDefault(ApiProperties.API_ENABLED, Boolean.class, false);
if (!apiEnabled) { if (!apiEnabled) {
getFailureHandler().onFailure(request, response, new ForbiddenException("API has been disabled by blogger currently")); throw new ForbiddenException("API has been disabled by blogger currently");
return;
} }
// Get access key // Get access key
@ -59,8 +62,7 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter {
if (StringUtils.isBlank(accessKey)) { if (StringUtils.isBlank(accessKey)) {
// If the access key is missing // If the access key is missing
getFailureHandler().onFailure(request, response, new AuthenticationException("Missing API access key")); throw new AuthenticationException("Missing API access key");
return;
} }
// Get access key from option // Get access key from option
@ -68,14 +70,12 @@ public class ApiAuthenticationFilter extends AbstractAuthenticationFilter {
if (!optionalAccessKey.isPresent()) { if (!optionalAccessKey.isPresent()) {
// If the access key is not set // If the access key is not set
getFailureHandler().onFailure(request, response, new AuthenticationException("API access key hasn't been set by blogger")); throw new AuthenticationException("API access key hasn't been set by blogger");
return;
} }
if (!StringUtils.equals(accessKey, optionalAccessKey.get())) { if (!StringUtils.equals(accessKey, optionalAccessKey.get())) {
// If the access key is mismatch // If the access key is mismatch
getFailureHandler().onFailure(request, response, new AuthenticationException("API access key is mismatch")); throw new AuthenticationException("API access key is mismatch").setErrorData(accessKey);
return;
} }
// Do filter // Do filter

View File

@ -2,6 +2,8 @@ package run.halo.app.security.filter;
import run.halo.app.cache.StringCacheStore; import run.halo.app.cache.StringCacheStore;
import run.halo.app.config.properties.HaloProperties; 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 run.halo.app.service.OptionService;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
@ -20,8 +22,10 @@ public class ContentFilter extends AbstractAuthenticationFilter {
public ContentFilter(HaloProperties haloProperties, public ContentFilter(HaloProperties haloProperties,
OptionService optionService, OptionService optionService,
StringCacheStore cacheStore) { StringCacheStore cacheStore,
super(haloProperties, optionService, cacheStore); OneTimeTokenService oneTimeTokenService,
Mode mode) {
super(haloProperties, optionService, cacheStore, oneTimeTokenService, mode);
} }
@Override @Override

View File

@ -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<String> 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);
}

View File

@ -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<String> 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);
}
}

View File

@ -26,9 +26,6 @@ public class SecurityUtils {
private final static String REFRESH_TOKEN_CACHE_PREFIX = "halo.admin.refresh_token."; 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() { private SecurityUtils() {
} }
@ -60,10 +57,4 @@ public class SecurityUtils {
return TOKEN_REFRESH_CACHE_PREFIX + refreshToken; 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;
}
} }

View File

@ -6,7 +6,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils; import org.apache.commons.lang3.time.DateFormatUtils;
import org.apache.http.client.utils.URIBuilder;
import org.json.JSONObject; import org.json.JSONObject;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
@ -15,7 +14,6 @@ import org.springframework.stereotype.Service;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import run.halo.app.cache.StringCacheStore;
import run.halo.app.config.properties.HaloProperties; import run.halo.app.config.properties.HaloProperties;
import run.halo.app.exception.NotFoundException; import run.halo.app.exception.NotFoundException;
import run.halo.app.exception.ServiceException; import run.halo.app.exception.ServiceException;
@ -24,7 +22,7 @@ import run.halo.app.model.dto.post.BasePostDetailDTO;
import run.halo.app.model.entity.Post; import run.halo.app.model.entity.Post;
import run.halo.app.model.entity.Tag; import run.halo.app.model.entity.Tag;
import run.halo.app.model.support.HaloConst; import run.halo.app.model.support.HaloConst;
import run.halo.app.security.util.SecurityUtils; import run.halo.app.security.service.OneTimeTokenService;
import run.halo.app.service.BackupService; import run.halo.app.service.BackupService;
import run.halo.app.service.OptionService; import run.halo.app.service.OptionService;
import run.halo.app.service.PostService; import run.halo.app.service.PostService;
@ -34,7 +32,6 @@ import run.halo.app.utils.HaloUtils;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.NoSuchFileException; import java.nio.file.NoSuchFileException;
@ -42,17 +39,10 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; 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. * Backup service implementation.
* *
@ -64,34 +54,40 @@ import static run.halo.app.model.support.HaloConst.TEMP_TOKEN_EXPIRATION;
@Slf4j @Slf4j
public class BackupServiceImpl implements BackupService { 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 static final String LINE_SEPARATOR = System.getProperty("line.separator");
private final PostService postService; private final PostService postService;
private final PostTagService postTagService; private final PostTagService postTagService;
private final OptionService optionService; private final OptionService optionService;
private final StringCacheStore cacheStore;
private final OneTimeTokenService oneTimeTokenService;
private final HaloProperties haloProperties; private final HaloProperties haloProperties;
public BackupServiceImpl(PostService postService, public BackupServiceImpl(PostService postService,
PostTagService postTagService, PostTagService postTagService,
OptionService optionService, OptionService optionService,
StringCacheStore stringCacheStore, OneTimeTokenService oneTimeTokenService,
HaloProperties haloProperties) { HaloProperties haloProperties) {
this.postService = postService; this.postService = postService;
this.postTagService = postTagService; this.postTagService = postTagService;
this.optionService = optionService; this.optionService = optionService;
this.cacheStore = stringCacheStore; this.oneTimeTokenService = oneTimeTokenService;
this.haloProperties = haloProperties; this.haloProperties = haloProperties;
} }
/** /**
* Sanitizes the specified file name. * Sanitizes the specified file name.
* *
* @param unsanitized the specified file name * @param unSanitized the specified file name
* @return sanitized file name * @return sanitized file name
*/ */
public static String sanitizeFilename(final String unsanitized) { public static String sanitizeFilename(final String unSanitized) {
return unsanitized. return unSanitized.
replaceAll("[^(a-zA-Z0-9\\u4e00-\\u9fa5\\.)]", ""). replaceAll("[^(a-zA-Z0-9\\u4e00-\\u9fa5\\.)]", "").
replaceAll("[\\?\\\\/:|<>\\*\\[\\]\\(\\)\\$%\\{\\}@~\\.]", ""). replaceAll("[\\?\\\\/:|<>\\*\\[\\]\\(\\)\\$%\\{\\}@~\\.]", "").
replaceAll("\\s", ""); replaceAll("\\s", "");
@ -191,8 +187,14 @@ public class BackupServiceImpl implements BackupService {
@Override @Override
public List<BackupDTO> listHaloBackups() { public List<BackupDTO> listHaloBackups() {
// Ensure the parent folder exist
Path backupParentPath = Paths.get(haloProperties.getBackupDir());
if (Files.notExists(backupParentPath)) {
return Collections.emptyList();
}
// Build backup dto // Build backup dto
try (Stream<Path> subPathStream = Files.list(Paths.get(haloProperties.getBackupDir()))) { try (Stream<Path> subPathStream = Files.list(backupParentPath)) {
return subPathStream return subPathStream
.filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_PREFIX)) .filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_PREFIX))
.map(this::buildBackupDto) .map(this::buildBackupDto)
@ -236,19 +238,32 @@ public class BackupServiceImpl implements BackupService {
public Resource loadFileAsResource(String fileName) { public Resource loadFileAsResource(String fileName) {
Assert.hasText(fileName, "Backup file name must not be blank"); Assert.hasText(fileName, "Backup file name must not be blank");
// Get backup file path Path backupParentPath = Paths.get(haloProperties.getBackupDir());
Path backupFilePath = Paths.get(haloProperties.getBackupDir(), fileName).normalize();
try { 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 // Build url resource
Resource backupResource = new UrlResource(backupFilePath.toUri()); Resource backupResource = new UrlResource(backupFilePath.toUri());
if (!backupResource.exists()) { 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"); throw new NotFoundException("The file " + fileName + " was not found");
} }
// Return the backup resource // Return the backup resource
return backupResource; return backupResource;
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
throw new NotFoundException("The file " + fileName + " was not found", 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 +286,6 @@ public class BackupServiceImpl implements BackupService {
backup.setFileSize(Files.size(backupPath)); backup.setFileSize(Files.size(backupPath));
} catch (IOException e) { } catch (IOException e) {
throw new ServiceException("Failed to access file " + backupPath, 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; return backup;
@ -284,36 +297,21 @@ public class BackupServiceImpl implements BackupService {
* @param filename filename must not be blank * @param filename filename must not be blank
* @return download url * @return download url
*/ */
private String buildDownloadUrl(@NonNull String filename) throws URISyntaxException { @NonNull
private String buildDownloadUrl(@NonNull String filename) {
Assert.hasText(filename, "File name must not be blank"); Assert.hasText(filename, "File name must not be blank");
// Composite http url // Composite http url
String backupFullUrl = HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), "api/admin/backups/halo", filename); String backupUri = BACKUP_RESOURCE_BASE_URI + HaloUtils.URL_SEPARATOR + filename;
// Get temp token // Get a one-time token
String tempToken = cacheStore.get(buildBackupTokenKey(filename)).orElseGet(() -> { String oneTimeToken = oneTimeTokenService.create(backupUri);
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(); // Build full url
return HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), backupUri)
+ "?"
+ HaloConst.ONE_TIME_TOKEN_QUERY_NAME
+ "=" + oneTimeToken;
} }
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;
}
} }

View File

@ -1,5 +1,5 @@
server: server:
port: 8090 port: 18090
forward-headers-strategy: native forward-headers-strategy: native
compression: compression:
enabled: false enabled: false
@ -59,4 +59,5 @@ logging:
halo: halo:
doc-disabled: false doc-disabled: false
auth-enable: false
workDir: ${user.home}/halo-test/ workDir: ${user.home}/halo-test/

View File

@ -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());
}
}