diff --git a/api/build.gradle b/api/build.gradle
index c04f50aadb6..690e28db07e 100644
--- a/api/build.gradle
+++ b/api/build.gradle
@@ -63,6 +63,8 @@ dependencies {
api "io.github.resilience4j:resilience4j-spring-boot3"
api "io.github.resilience4j:resilience4j-reactor"
+ api "com.j256.two-factor-auth:two-factor-auth"
+
runtimeOnly 'io.r2dbc:r2dbc-h2'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.postgresql:r2dbc-postgresql'
diff --git a/api/src/main/java/run/halo/app/core/extension/User.java b/api/src/main/java/run/halo/app/core/extension/User.java
index 60e443b6aad..a3c89c9ccb3 100644
--- a/api/src/main/java/run/halo/app/core/extension/User.java
+++ b/api/src/main/java/run/halo/app/core/extension/User.java
@@ -45,9 +45,9 @@ public class User extends AbstractExtension {
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
@Schema(requiredMode = REQUIRED)
- private UserSpec spec;
+ private UserSpec spec = new UserSpec();
- private UserStatus status;
+ private UserStatus status = new UserStatus();
@Data
public static class UserSpec {
@@ -72,6 +72,8 @@ public static class UserSpec {
private Boolean twoFactorAuthEnabled;
+ private String totpEncryptedSecret;
+
private Boolean disabled;
private Integer loginHistoryLimit;
diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java
index 9b150b07469..c68031e8ad1 100644
--- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java
+++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java
@@ -31,7 +31,6 @@
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
-import run.halo.app.security.DefaultServerAuthenticationEntryPoint;
import run.halo.app.security.DefaultUserDetailService;
import run.halo.app.security.DynamicMatcherSecurityWebFilterChain;
import run.halo.app.security.authentication.SecurityConfigurer;
@@ -79,12 +78,11 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http,
var authManagerResolver = builder().add(
new PatServerWebExchangeMatcher(),
new PatAuthenticationManager(client, patJwkSupplier))
- // TODO Add other authentication mangers here. e.g.: JwtAuthentiationManager.
+ // TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager.
.build();
oauth2.authenticationManagerResolver(authManagerResolver);
})
- .exceptionHandling(
- spec -> spec.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint()));
+ ;
// Integrate with other configurers separately
securityConfigurers.orderedStream()
diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java
deleted file mode 100644
index 9152f77319e..00000000000
--- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package run.halo.app.core.extension.service;
-
-import java.util.Collection;
-import java.util.Set;
-import java.util.stream.Collectors;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
-import run.halo.app.security.authorization.AuthorityUtils;
-
-/**
- *
Obtain the authorities from the authenticated authentication and construct it as a RoleBinding
- * list.
- * After JWT authentication, the roles stored in the authorities are the roles owned by the user,
- * so there is no need to query from the database.
- * For tokens in other formats, after authentication, fill the authorities with the token into
- * the SecurityContextHolder.
- *
- * @author guqing
- * @see AnonymousAuthenticationFilter
- * @since 2.0.0
- */
-@Slf4j
-public class DefaultRoleBindingService implements RoleBindingService {
- private static final String SCOPE_AUTHORITY_PREFIX = AuthorityUtils.SCOPE_PREFIX;
- private static final String ROLE_AUTHORITY_PREFIX = AuthorityUtils.ROLE_PREFIX;
-
- @Override
- public Set listBoundRoleNames(Collection extends GrantedAuthority> authorities) {
- return authorities.stream()
- .map(GrantedAuthority::getAuthority)
- // Exclude anonymous user roles
- .filter(authority -> !authority.equals("ROLE_ANONYMOUS"))
- .map(scope -> {
- if (scope.startsWith(SCOPE_AUTHORITY_PREFIX)) {
- scope = scope.replaceFirst(SCOPE_AUTHORITY_PREFIX, "");
- // keep checking the ROLE_ here
- }
- if (scope.startsWith(ROLE_AUTHORITY_PREFIX)) {
- return scope.replaceFirst(ROLE_AUTHORITY_PREFIX, "");
- }
- return scope;
- })
- .collect(Collectors.toSet());
- }
-}
diff --git a/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java b/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java
deleted file mode 100644
index 9b19082ba46..00000000000
--- a/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package run.halo.app.core.extension.service;
-
-import java.util.Collection;
-import java.util.Set;
-import org.springframework.security.core.GrantedAuthority;
-
-/**
- * @author guqing
- * @since 2.0.0
- */
-@FunctionalInterface
-public interface RoleBindingService {
-
- Set listBoundRoleNames(Collection extends GrantedAuthority> authorities);
-
-}
diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java
new file mode 100644
index 00000000000..4ba0069542c
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java
@@ -0,0 +1,63 @@
+package run.halo.app.infra.exception;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.springframework.context.MessageSource;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.http.ProblemDetail;
+import org.springframework.lang.Nullable;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.server.ServerWebInputException;
+import org.springframework.web.util.BindErrorUtils;
+
+public class RequestBodyValidationException extends ServerWebInputException {
+
+ private final BindingResult bindingResult;
+
+ public RequestBodyValidationException(BindingResult bindingResult) {
+ super("Validation failure", null, null, null, null);
+ this.bindingResult = bindingResult;
+ }
+
+ @Override
+ public ProblemDetail updateAndGetBody(MessageSource messageSource, Locale locale) {
+ var detail = super.updateAndGetBody(messageSource, locale);
+ detail.setProperty("errors", collectAllErrors(messageSource, locale));
+ return detail;
+ }
+
+ private List collectAllErrors(MessageSource messageSource, Locale locale) {
+ var globalErrors = resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale);
+ var fieldErrors = resolveErrors(bindingResult.getFieldErrors(), messageSource, locale);
+ var errors = new ArrayList(globalErrors.size() + fieldErrors.size());
+ errors.addAll(globalErrors);
+ errors.addAll(fieldErrors);
+ return errors;
+ }
+
+ @Override
+ public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) {
+ return new Object[] {
+ resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale),
+ resolveErrors(bindingResult.getFieldErrors(), messageSource, locale)
+ };
+ }
+
+ @Override
+ public Object[] getDetailMessageArguments() {
+ return new Object[] {
+ resolveErrors(bindingResult.getGlobalErrors(), null, Locale.getDefault()),
+ resolveErrors(bindingResult.getFieldErrors(), null, Locale.getDefault())
+ };
+ }
+
+ private static List resolveErrors(
+ List extends MessageSourceResolvable> errors,
+ @Nullable MessageSource messageSource,
+ Locale locale) {
+ return messageSource == null
+ ? BindErrorUtils.resolve(errors).values().stream().toList()
+ : BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList();
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java
index 95bf446c563..528f5253ba2 100644
--- a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java
+++ b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java
@@ -2,8 +2,12 @@
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
+import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
+import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME;
+import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX;
import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
@@ -16,6 +20,7 @@
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupKind;
import run.halo.app.infra.exception.UserNotFoundException;
+import run.halo.app.security.authentication.login.HaloUser;
public class DefaultUserDetailService
implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
@@ -43,15 +48,19 @@ public Mono findByUsername(String username) {
.flatMap(user -> {
var name = user.getMetadata().getName();
var subject = new Subject(KIND, name, GROUP);
- return roleService.listRoleRefs(subject)
+
+ var builder = new HaloUser.Builder(user);
+
+ var setAuthorities = roleService.listRoleRefs(subject)
.filter(this::isRoleRef)
.map(RoleRef::getName)
+ // every authenticated user should have authenticated and anonymous roles.
+ .concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME)
+ .map(roleName -> new SimpleGrantedAuthority(ROLE_PREFIX + roleName))
.collectList()
- .map(roleNames -> User.builder()
- .username(name)
- .password(user.getSpec().getPassword())
- .roles(roleNames.toArray(new String[0]))
- .build());
+ .doOnNext(builder::authorities);
+
+ return setAuthorities.then(Mono.fromSupplier(builder::build));
});
}
diff --git a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java
new file mode 100644
index 00000000000..1ccbff9f12a
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java
@@ -0,0 +1,61 @@
+package run.halo.app.security;
+
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
+import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
+import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler;
+import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.authentication.SecurityConfigurer;
+import run.halo.app.security.authentication.mfa.MfaAuthentication;
+import run.halo.app.security.authentication.mfa.MfaResponseHandler;
+
+@Component
+public class ExceptionSecurityConfigurer implements SecurityConfigurer {
+
+ private final MfaResponseHandler mfaResponseHandler;
+
+ public ExceptionSecurityConfigurer(MfaResponseHandler mfaResponseHandler) {
+ this.mfaResponseHandler = mfaResponseHandler;
+ }
+
+ @Override
+ public void configure(ServerHttpSecurity http) {
+ http.exceptionHandling(exception -> {
+ var mfaAccessDeniedHandler = new MfaAccessDeniedHandler();
+ var mfaEntry =
+ new DelegateEntry(mfaAccessDeniedHandler.getMatcher(), mfaAccessDeniedHandler);
+ var accessDeniedHandler =
+ new ServerWebExchangeDelegatingServerAccessDeniedHandler(mfaEntry);
+ accessDeniedHandler.setDefaultAccessDeniedHandler(
+ new BearerTokenServerAccessDeniedHandler());
+ exception.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint())
+ .accessDeniedHandler(accessDeniedHandler);
+ });
+ }
+
+ private class MfaAccessDeniedHandler implements ServerAccessDeniedHandler {
+
+ private final ServerWebExchangeMatcher matcher;
+
+ private MfaAccessDeniedHandler() {
+ matcher = exchange -> exchange.getPrincipal()
+ .filter(MfaAuthentication.class::isInstance)
+ .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match())
+ .switchIfEmpty(Mono.defer(ServerWebExchangeMatcher.MatchResult::notMatch));
+ }
+
+ @Override
+ public Mono handle(ServerWebExchange exchange, AccessDeniedException denied) {
+ return mfaResponseHandler.handle(exchange);
+ }
+
+ public ServerWebExchangeMatcher getMatcher() {
+ return matcher;
+ }
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java
new file mode 100644
index 00000000000..efcb482d526
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java
@@ -0,0 +1,52 @@
+package run.halo.app.security;
+
+import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING;
+import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
+
+import java.net.URI;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.server.WebFilterExchange;
+import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
+import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
+import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.authentication.SecurityConfigurer;
+
+@Component
+public class LogoutSecurityConfigurer implements SecurityConfigurer {
+
+ @Override
+ public void configure(ServerHttpSecurity http) {
+ http.logout(logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler()));
+ http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
+ }
+
+ public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
+
+ private final ServerLogoutSuccessHandler defaultHandler;
+
+ public LogoutSuccessHandler() {
+ var defaultHandler = new RedirectServerLogoutSuccessHandler();
+ defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout"));
+ this.defaultHandler = defaultHandler;
+ }
+
+ @Override
+ public Mono onLogoutSuccess(WebFilterExchange exchange,
+ Authentication authentication) {
+ return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
+ .flatMap(matchResult -> {
+ if (matchResult.isMatch()) {
+ var response = exchange.getExchange().getResponse();
+ response.setStatusCode(HttpStatus.NO_CONTENT);
+ return response.setComplete();
+ }
+ return defaultHandler.onLogoutSuccess(exchange, authentication);
+ });
+ }
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java
index 7a328084563..e4807b2c9ef 100644
--- a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java
+++ b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java
@@ -1,5 +1,9 @@
package run.halo.app.security.authentication.jwt;
+import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
+import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME;
+import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX;
+
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.core.convert.converter.Converter;
@@ -7,9 +11,7 @@
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
-import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
-import run.halo.app.security.authorization.AuthorityUtils;
/**
* GrantedAuthorities converter for SCOPE_ and ROLE_ prefixes.
@@ -17,7 +19,7 @@
* @author johnniang
*/
public class JwtScopesAndRolesGrantedAuthoritiesConverter
- implements Converter> {
+ implements Converter> {
private final Converter> delegate;
@@ -28,17 +30,23 @@ public JwtScopesAndRolesGrantedAuthoritiesConverter() {
@Override
public Flux convert(Jwt jwt) {
var grantedAuthorities = new ArrayList();
+
+ // add default roles
+ grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + ANONYMOUS_ROLE_NAME));
+ grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + AUTHENTICATED_ROLE_NAME));
+
var delegateAuthorities = delegate.convert(jwt);
if (delegateAuthorities != null) {
grantedAuthorities.addAll(delegateAuthorities);
}
var roles = jwt.getClaimAsStringList("roles");
- if (!CollectionUtils.isEmpty(roles)) {
+ if (roles != null) {
roles.stream()
- .map(role -> AuthorityUtils.ROLE_PREFIX + role)
+ .map(role -> ROLE_PREFIX + role)
.map(SimpleGrantedAuthority::new)
.forEach(grantedAuthorities::add);
}
+
return Flux.fromIterable(grantedAuthorities);
}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java b/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java
deleted file mode 100644
index dcd3db1e680..00000000000
--- a/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package run.halo.app.security.authentication.login;
-
-import org.springframework.lang.NonNull;
-import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
-import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
-import org.springframework.stereotype.Component;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.server.WebFilterChain;
-import reactor.core.publisher.Mono;
-import run.halo.app.security.AdditionalWebFilter;
-
-/**
- * Generates a default log out page.
- *
- * @author guqing
- * @since 2.4.0
- */
-@Component
-public class DelegatingLogoutPageGeneratingWebFilter implements AdditionalWebFilter {
- private final LogoutPageGeneratingWebFilter logoutPageGeneratingWebFilter =
- new LogoutPageGeneratingWebFilter();
-
- @Override
- @NonNull
- public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
- return logoutPageGeneratingWebFilter.filter(exchange, chain);
- }
-
- @Override
- public int getOrder() {
- return SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING.getOrder();
- }
-}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java
new file mode 100644
index 00000000000..6104f14a84e
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java
@@ -0,0 +1,113 @@
+package run.halo.app.security.authentication.login;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import org.springframework.security.core.CredentialsContainer;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.util.Assert;
+import run.halo.app.core.extension.User;
+
+public class HaloUser implements UserDetails, CredentialsContainer {
+
+ private final User delegate;
+
+ private final Collection extends GrantedAuthority> authorities;
+
+ public HaloUser(User delegate, Collection extends GrantedAuthority> authorities) {
+ Assert.notNull(delegate, "Delegate user must not be null");
+ Assert.notNull(authorities, "Authorities must not be null");
+ this.delegate = delegate;
+
+ this.authorities = authorities.stream()
+ .filter(Objects::nonNull)
+ .sorted(Comparator.comparing(GrantedAuthority::getAuthority))
+ .toList();
+ }
+
+ public HaloUser(User delegate) {
+ this(delegate, List.of());
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return authorities;
+ }
+
+ @Override
+ public String getPassword() {
+ return delegate.getSpec().getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return delegate.getMetadata().getName();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ var disabled = delegate.getSpec().getDisabled();
+ return disabled == null || !disabled;
+ }
+
+ public User getDelegate() {
+ return delegate;
+ }
+
+ @Override
+ public void eraseCredentials() {
+ delegate.getSpec().setPassword(null);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof HaloUser user) {
+ var username = this.delegate.getMetadata().getName();
+ var otherUsername = user.delegate.getMetadata().getName();
+ return username.equals(otherUsername);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return this.delegate.getMetadata().getName().hashCode();
+ }
+
+ public static class Builder {
+
+ private final User user;
+
+ private Collection extends GrantedAuthority> authorities;
+
+ public Builder(User user) {
+ this.user = user;
+ }
+
+ public Builder authorities(Collection extends GrantedAuthority> authorities) {
+ this.authorities = authorities;
+ return this;
+ }
+
+ public HaloUser build() {
+ return new HaloUser(user, authorities);
+ }
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java
index 26479199ec0..827c28e555d 100644
--- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java
+++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java
@@ -42,6 +42,8 @@
import run.halo.app.infra.utils.IpAddressUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.AdditionalWebFilter;
+import run.halo.app.security.authentication.mfa.MfaAuthentication;
+import run.halo.app.security.authentication.mfa.TwoFactorUtils;
/**
* Authentication filter for username and password.
@@ -112,8 +114,7 @@ void configureAuthenticationWebFilter(AuthenticationWebFilter filter) {
filter.setRequiresAuthenticationMatcher(requiresMatcher);
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
- filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService
- ));
+ filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService));
filter.setSecurityContextRepository(securityContextRepository);
}
@@ -152,7 +153,7 @@ private Mono handleRateLimitExceededException(RateLimitExceededException e
return writeErrorResponse(errorResponse, exchange);
}
- private Mono handleAuthenticationException(AuthenticationException exception,
+ private Mono handleAuthenticationException(Throwable exception,
ServerWebExchange exchange) {
var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource);
return writeErrorResponse(errorResponse, exchange);
@@ -176,6 +177,15 @@ public UsernamePasswordAuthenticationWebFilter(
@Override
protected Mono onAuthenticationSuccess(Authentication authentication,
WebFilterExchange webFilterExchange) {
+ // check if MFA is enabled
+ if (authentication.getPrincipal() instanceof HaloUser user) {
+ var twoFactorAuthSettings =
+ TwoFactorUtils.getTwoFactorAuthSettings(user.getDelegate());
+ if (twoFactorAuthSettings.isAvailable()) {
+ authentication = new MfaAuthentication(authentication);
+ }
+ }
+
return super.onAuthenticationSuccess(authentication, webFilterExchange)
.transformDeferred(createIPBasedRateLimiter(webFilterExchange.getExchange()))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
@@ -189,28 +199,39 @@ public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ServerAuthenticationSuccessHandler defaultHandler =
new RedirectServerAuthenticationSuccessHandler("/console/");
+ private final ServerAuthenticationSuccessHandler mfaRedirectHandler =
+ new RedirectServerAuthenticationSuccessHandler("/console/mfa/totp");
+
@Override
public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
+
+ if (authentication instanceof MfaAuthentication) {
+ // continue filtering for authorization
+ return webFilterExchange.getChain().filter(webFilterExchange.getExchange());
+ }
+
+ ServerWebExchangeMatcher xhrMatcher = exchange -> {
+ if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
+ .contains("XMLHttpRequest")) {
+ return ServerWebExchangeMatcher.MatchResult.match();
+ }
+ return ServerWebExchangeMatcher.MatchResult.notMatch();
+ };
+
var exchange = webFilterExchange.getExchange();
- return ignoringMediaTypeAll(APPLICATION_JSON)
- .matches(exchange)
+ return xhrMatcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
- .switchIfEmpty(
- defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
- .then(Mono.empty())
- )
- .flatMap(matchResult -> {
- var principal = authentication.getPrincipal();
- if (principal instanceof CredentialsContainer credentialsContainer) {
- credentialsContainer.eraseCredentials();
+ .switchIfEmpty(Mono.defer(
+ () -> defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
+ .then(Mono.empty())))
+ .flatMap(isXhr -> {
+ if (authentication instanceof CredentialsContainer container) {
+ container.eraseCredentials();
}
-
return ServerResponse.ok()
- .contentType(APPLICATION_JSON)
- .bodyValue(principal)
- .flatMap(serverResponse ->
- serverResponse.writeTo(exchange, context));
+ .bodyValue(authentication.getPrincipal())
+ .flatMap(response -> response.writeTo(exchange, context));
});
}
}
diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java
deleted file mode 100644
index 7259569b611..00000000000
--- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package run.halo.app.security.authentication.login;
-
-import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
-
-import java.net.URI;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.lang.NonNull;
-import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.web.server.WebFilterExchange;
-import org.springframework.security.web.server.authentication.logout.LogoutWebFilter;
-import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
-import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
-import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
-import org.springframework.security.web.server.context.ServerSecurityContextRepository;
-import org.springframework.stereotype.Component;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.server.WebFilterChain;
-import reactor.core.publisher.Mono;
-import run.halo.app.security.AdditionalWebFilter;
-
-/**
- * Logout handler for username password authentication.
- *
- * @author guqing
- * @since 2.4.0
- */
-@Component
-public class UsernamePasswordLogoutHandler implements AdditionalWebFilter {
- private final ServerSecurityContextRepository securityContextRepository;
- private final LogoutWebFilter logoutWebFilter;
-
- /**
- * Constructs a {@link UsernamePasswordLogoutHandler} with the given
- * {@link ServerSecurityContextRepository}.
- * It will create a {@link LogoutWebFilter} instance and configure it.
- *
- * @param securityContextRepository a {@link ServerSecurityContextRepository} instance
- */
- public UsernamePasswordLogoutHandler(
- ServerSecurityContextRepository securityContextRepository) {
- this.securityContextRepository = securityContextRepository;
-
- this.logoutWebFilter = new LogoutWebFilter();
- configureLogoutWebFilter(logoutWebFilter);
- }
-
- @Override
- @NonNull
- public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
- return logoutWebFilter.filter(exchange, chain);
- }
-
- @Override
- public int getOrder() {
- return SecurityWebFiltersOrder.LOGOUT.getOrder();
- }
-
- void configureLogoutWebFilter(LogoutWebFilter filter) {
- var securityContextServerLogoutHandler = new SecurityContextServerLogoutHandler();
- securityContextServerLogoutHandler.setSecurityContextRepository(securityContextRepository);
- filter.setLogoutHandler(securityContextServerLogoutHandler);
- filter.setLogoutSuccessHandler(new LogoutSuccessHandler());
- }
-
- /**
- * Success handler for logout.
- *
- * @author johnniang
- */
- public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
-
- private final ServerLogoutSuccessHandler defaultHandler;
-
- public LogoutSuccessHandler() {
- var defaultHandler = new RedirectServerLogoutSuccessHandler();
- defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout"));
- this.defaultHandler = defaultHandler;
- }
-
- @Override
- public Mono onLogoutSuccess(WebFilterExchange exchange,
- Authentication authentication) {
- return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
- .flatMap(matchResult -> {
- if (matchResult.isMatch()) {
- exchange.getExchange().getResponse().setStatusCode(HttpStatus.OK);
- return Mono.empty();
- }
- return defaultHandler.onLogoutSuccess(exchange, authentication);
- });
- }
- }
-}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultMfaResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultMfaResponseHandler.java
new file mode 100644
index 00000000000..a1cc6023979
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultMfaResponseHandler.java
@@ -0,0 +1,55 @@
+package run.halo.app.security.authentication.mfa;
+
+import java.net.URI;
+import org.springframework.context.MessageSource;
+import org.springframework.security.web.server.DefaultServerRedirectStrategy;
+import org.springframework.security.web.server.ServerRedirectStrategy;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import run.halo.app.infra.exception.Exceptions;
+
+@Component
+public class DefaultMfaResponseHandler implements MfaResponseHandler {
+
+ private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+
+ private static final String REDIRECT_LOCATION = "/console/login/mfa";
+
+ private final MessageSource messageSource;
+
+ private final ServerResponse.Context context;
+
+ private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> {
+ if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
+ .contains("XMLHttpRequest")) {
+ return ServerWebExchangeMatcher.MatchResult.match();
+ }
+ return ServerWebExchangeMatcher.MatchResult.notMatch();
+ };
+
+ public DefaultMfaResponseHandler(MessageSource messageSource, ServerResponse.Context context) {
+ this.messageSource = messageSource;
+ this.context = context;
+ }
+
+ @Override
+ public Mono handle(ServerWebExchange exchange) {
+ return XHR_MATCHER.matches(exchange)
+ .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+ .switchIfEmpty(Mono.defer(
+ () -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION))
+ .then(Mono.empty())))
+ .flatMap(isXhr -> {
+ var errorResponse = Exceptions.createErrorResponse(
+ new MfaRequiredException(URI.create(REDIRECT_LOCATION)), null, exchange,
+ messageSource);
+ return ServerResponse.status(errorResponse.getStatusCode())
+ .bodyValue(errorResponse.getBody())
+ .flatMap(response -> response.writeTo(exchange, context));
+ });
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultTwoFactorAuthService.java
new file mode 100644
index 00000000000..3d2d333d576
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/DefaultTwoFactorAuthService.java
@@ -0,0 +1,107 @@
+package run.halo.app.security.authentication.mfa;
+
+import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.generateBase32Secret;
+import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.validateCurrentNumber;
+import static java.nio.file.StandardOpenOption.APPEND;
+import static java.nio.file.StandardOpenOption.CREATE;
+import static java.nio.file.StandardOpenOption.READ;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.UnrecoverableEntryException;
+import java.security.cert.CertificateException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.codec.Hex;
+import org.springframework.security.crypto.encrypt.AesBytesEncryptor;
+import org.springframework.security.crypto.encrypt.BytesEncryptor;
+import org.springframework.security.crypto.keygen.KeyGenerators;
+import org.springframework.stereotype.Component;
+import run.halo.app.infra.properties.HaloProperties;
+
+@Slf4j
+@Component
+public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
+
+ private final BytesEncryptor encryptor;
+
+ public DefaultTwoFactorAuthService(HaloProperties haloProperties) {
+ // init secret key
+ var keysRoot = haloProperties.getWorkDir().resolve("keys");
+ this.encryptor = createEncryptor(keysRoot);
+ }
+
+ private BytesEncryptor createEncryptor(Path keysRoot) {
+ try {
+ if (Files.notExists(keysRoot)) {
+ Files.createDirectories(keysRoot);
+ }
+ var keyStorePath = keysRoot.resolve("halo.keystore");
+ var password = "changeit".toCharArray();
+ var keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ if (Files.notExists(keyStorePath)) {
+ keyStore.load(null, password);
+ } else {
+ try (var is = Files.newInputStream(keyStorePath, READ)) {
+ keyStore.load(is, password);
+ }
+ }
+
+ var alias = "totp-secret-key";
+ var entry = keyStore.getEntry(alias, new KeyStore.PasswordProtection(password));
+ SecretKey secretKey = null;
+ if (entry instanceof KeyStore.SecretKeyEntry secretKeyEntry) {
+ if ("AES".equalsIgnoreCase(secretKeyEntry.getSecretKey().getAlgorithm())) {
+ secretKey = secretKeyEntry.getSecretKey();
+ }
+ }
+ if (secretKey == null) {
+ var generator = KeyGenerator.getInstance("AES");
+ generator.init(128);
+ secretKey = generator.generateKey();
+ var secretKeyEntry = new KeyStore.SecretKeyEntry(secretKey);
+ keyStore.setEntry(alias, secretKeyEntry, new KeyStore.PasswordProtection(password));
+ try (var os = Files.newOutputStream(keyStorePath, CREATE, APPEND)) {
+ keyStore.store(os, password);
+ }
+ }
+ return new AesBytesEncryptor(secretKey,
+ KeyGenerators.secureRandom(32),
+ AesBytesEncryptor.CipherAlgorithm.GCM);
+ } catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException
+ | UnrecoverableEntryException e) {
+ throw new RuntimeException("Failed to initialize AesBytesEncryptor", e);
+ }
+ }
+
+ @Override
+ public boolean validateTotp(String rawSecret, int code) {
+ try {
+ return validateCurrentNumber(rawSecret, code, 10 * 1000);
+ } catch (GeneralSecurityException e) {
+ log.warn("Error occurred when validate TOTP code", e);
+ return false;
+ }
+ }
+
+ @Override
+ public String generateTotpSecret() {
+ return generateBase32Secret(32);
+ }
+
+ @Override
+ public String encryptSecret(String rawSecret) {
+ return new String(Hex.encode(encryptor.encrypt(rawSecret.getBytes())));
+ }
+
+ @Override
+ public String decryptSecret(String encryptedSecret) {
+ return new String(encryptor.decrypt(Hex.decode(encryptedSecret)));
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/MfaAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaAuthentication.java
new file mode 100644
index 00000000000..97cc2312bdc
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaAuthentication.java
@@ -0,0 +1,42 @@
+package run.halo.app.security.authentication.mfa;
+
+import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
+
+import java.util.List;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+public class MfaAuthentication extends AbstractAuthenticationToken {
+
+ private final Authentication previous;
+
+ /**
+ * Creates a token with the supplied array of authorities.
+ *
+ * @param previous the previous authentication
+ */
+ public MfaAuthentication(Authentication previous) {
+ super(List.of(new SimpleGrantedAuthority(ANONYMOUS_ROLE_NAME)));
+ this.previous = previous;
+ }
+
+ @Override
+ public Object getCredentials() {
+ return previous.getCredentials();
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return previous.getPrincipal();
+ }
+
+ @Override
+ public boolean isAuthenticated() {
+ return true;
+ }
+
+ public Authentication getPrevious() {
+ return previous;
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/MfaRequiredException.java b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaRequiredException.java
new file mode 100644
index 00000000000..681ebc4d232
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaRequiredException.java
@@ -0,0 +1,17 @@
+package run.halo.app.security.authentication.mfa;
+
+import java.net.URI;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public class MfaRequiredException extends ResponseStatusException {
+
+ private static final URI type = URI.create("https://halo.run/probs/mfa-required");
+
+ public MfaRequiredException(URI redirectURI) {
+ super(HttpStatus.UNAUTHORIZED, "MFA required");
+ setType(type);
+ getBody().setProperty("redirectURI", redirectURI);
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/MfaResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaResponseHandler.java
new file mode 100644
index 00000000000..ac2e37aaaa5
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaResponseHandler.java
@@ -0,0 +1,10 @@
+package run.halo.app.security.authentication.mfa;
+
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+public interface MfaResponseHandler {
+
+ Mono handle(ServerWebExchange exchange);
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/MfaSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaSecurityConfigurer.java
new file mode 100644
index 00000000000..9240664fab5
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/MfaSecurityConfigurer.java
@@ -0,0 +1,27 @@
+package run.halo.app.security.authentication.mfa;
+
+import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.context.ServerSecurityContextRepository;
+import org.springframework.stereotype.Component;
+import run.halo.app.security.authentication.SecurityConfigurer;
+
+@Component
+public class MfaSecurityConfigurer implements SecurityConfigurer {
+
+ private final ServerSecurityContextRepository securityContextRepository;
+ private final TwoFactorAuthService twoFactorAuthService;
+
+ public MfaSecurityConfigurer(ServerSecurityContextRepository securityContextRepository,
+ TwoFactorAuthService twoFactorAuthService) {
+ this.securityContextRepository = securityContextRepository;
+ this.twoFactorAuthService = twoFactorAuthService;
+ }
+
+ @Override
+ public void configure(ServerHttpSecurity http) {
+ http.addFilterAfter(new TotpAuthenticationFilter(securityContextRepository,
+ twoFactorAuthService), SecurityWebFiltersOrder.AUTHENTICATION);
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationFilter.java
new file mode 100644
index 00000000000..eca01e0cd45
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationFilter.java
@@ -0,0 +1,116 @@
+package run.halo.app.security.authentication.mfa;
+
+import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.security.web.server.context.ServerSecurityContextRepository;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import run.halo.app.security.authentication.login.HaloUser;
+
+@Slf4j
+public class TotpAuthenticationFilter extends AuthenticationWebFilter {
+
+ public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
+ TwoFactorAuthService twoFactorAuthService) {
+ super(new MfaAuthenticationManager(twoFactorAuthService));
+ setSecurityContextRepository(securityContextRepository);
+ setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/mfa/totp"));
+ setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
+ setAuthenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/console"));
+ setAuthenticationFailureHandler(
+ new RedirectServerAuthenticationFailureHandler("/console/login/mfa?failed"));
+ }
+
+ private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter {
+
+ private final String codeParameter = "code";
+
+ @Override
+ public Mono convert(ServerWebExchange exchange) {
+ // Check the request is authenticated before.
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .filter(MfaAuthentication.class::isInstance)
+ .switchIfEmpty(Mono.error(
+ () -> new MfaAuthenticationException("MFA Authentication required.")))
+ .flatMap(authentication -> exchange.getFormData())
+ .handle((formData, sink) -> {
+ var codeStr = formData.getFirst(codeParameter);
+ try {
+ var code = Integer.parseInt(codeStr);
+ sink.next(new TotpAuthenticationToken(code));
+ } catch (NumberFormatException e) {
+ sink.error(
+ new MfaAuthenticationException("Invalid code parameter " + codeStr));
+ }
+ });
+ }
+ }
+
+ private static class MfaAuthenticationException extends AuthenticationException {
+
+ public MfaAuthenticationException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
+
+ public MfaAuthenticationException(String msg) {
+ super(msg);
+ }
+
+ }
+
+ private static class MfaAuthenticationManager implements ReactiveAuthenticationManager {
+
+ private final TwoFactorAuthService twoFactorAuthService;
+
+ private MfaAuthenticationManager(TwoFactorAuthService twoFactorAuthService) {
+ this.twoFactorAuthService = twoFactorAuthService;
+ }
+
+ @Override
+ public Mono authenticate(Authentication authentication) {
+ // it should be TotpAuthenticationToken
+ var code = (Integer) authentication.getCredentials();
+ log.debug("Got TOTP code {}", code);
+ // get user details
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .cast(MfaAuthentication.class)
+ .map(MfaAuthentication::getPrevious)
+ .doOnNext(previousAuth -> {
+ var principal = previousAuth.getPrincipal();
+ if (!(principal instanceof HaloUser haloUser)) {
+ throw (new MfaAuthenticationException("Invalid MFA authentication."));
+ }
+ var encryptedSecret =
+ haloUser.getDelegate().getSpec().getTotpEncryptedSecret();
+ if (StringUtils.isBlank(encryptedSecret)) {
+ throw new MfaAuthenticationException("Empty secret configured");
+ }
+ var rawSecret = twoFactorAuthService.decryptSecret(encryptedSecret);
+ var validated = twoFactorAuthService.validateTotp(rawSecret, code);
+ if (!validated) {
+ throw (new MfaAuthenticationException("Invalid TOTP code" + code));
+ }
+ })
+ .doOnNext(previousAuth -> {
+ if (log.isDebugEnabled()) {
+ log.debug("TOTP authentication for {} with code {} successfully.",
+ previousAuth.getName(), code);
+ }
+ });
+ }
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationToken.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationToken.java
new file mode 100644
index 00000000000..07963166cc6
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TotpAuthenticationToken.java
@@ -0,0 +1,33 @@
+package run.halo.app.security.authentication.mfa;
+
+import java.util.List;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+
+public class TotpAuthenticationToken extends AbstractAuthenticationToken {
+
+ private final int code;
+
+ public TotpAuthenticationToken(int code) {
+ super(List.of());
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public Object getCredentials() {
+ return getCode();
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return getCode();
+ }
+
+ @Override
+ public boolean isAuthenticated() {
+ return false;
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthEndpoint.java
new file mode 100644
index 00000000000..247e17817ef
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthEndpoint.java
@@ -0,0 +1,282 @@
+package run.halo.app.security.authentication.mfa;
+
+import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
+import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
+import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.net.URI;
+import lombok.Data;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.Validator;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import org.springframework.web.server.ServerWebInputException;
+import org.springframework.web.util.UriComponentsBuilder;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.User;
+import run.halo.app.core.extension.endpoint.CustomEndpoint;
+import run.halo.app.core.extension.service.UserService;
+import run.halo.app.extension.GroupVersion;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.exception.AccessDeniedException;
+import run.halo.app.infra.exception.RequestBodyValidationException;
+
+@Component
+public class TwoFactorAuthEndpoint implements CustomEndpoint {
+
+ private final ReactiveExtensionClient client;
+
+ private final UserService userService;
+
+ private final TwoFactorAuthService twoFactorAuthService;
+
+ private final Validator validator;
+
+ private final PasswordEncoder passwordEncoder;
+
+ private final ExternalUrlSupplier externalUrl;
+
+ public TwoFactorAuthEndpoint(ReactiveExtensionClient client,
+ UserService userService,
+ TwoFactorAuthService twoFactorAuthService,
+ Validator validator,
+ PasswordEncoder passwordEncoder,
+ ExternalUrlSupplier externalUrl) {
+ this.client = client;
+ this.userService = userService;
+ this.twoFactorAuthService = twoFactorAuthService;
+ this.validator = validator;
+ this.passwordEncoder = passwordEncoder;
+ this.externalUrl = externalUrl;
+ }
+
+ @Override
+ public RouterFunction endpoint() {
+ var tag = groupVersion() + "/Authentication/TwoFactor";
+ return route().nest(path("/authentications/two-factor"),
+ () -> route()
+ .GET("/settings", this::getTwoFactorSettings,
+ builder -> builder.operationId("GetTwoFactorAuthenticationSettings")
+ .tag(tag)
+ .description("Get Two-factor authentication settings.")
+ .response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
+ .PUT("/settings/enabled", this::enableTwoFactor,
+ builder -> builder.operationId("EnableTwoFactor")
+ .tag(tag)
+ .description("Enable Two-factor authentication")
+ .requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
+ .response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
+ .PUT("/settings/disabled", this::disableTwoFactor,
+ builder -> builder.operationId("DisableTwoFactor")
+ .tag(tag)
+ .description("Disable Two-factor authentication")
+ .requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
+ .response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
+ .POST("/totp", this::configureTotp,
+ builder -> builder.operationId("ConfigurerTotp")
+ .tag(tag)
+ .description("Configure a TOTP")
+ .requestBody(requestBodyBuilder().implementation(TotpRequest.class))
+ .response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
+ .DELETE("/totp/-", this::deleteTotp,
+ builder -> builder.operationId("DeleteTotp")
+ .tag(tag)
+ .requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
+ .response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
+ .GET("/totp/auth-link", this::getTotpAuthLink,
+ builder -> builder.operationId("GetTotpAuthLink")
+ .tag(tag)
+ .description("Get TOTP auth link, including secret")
+ .response(responseBuilder().implementation(TotpAuthLinkResponse.class)))
+ .build(),
+ builder -> builder.description("Two-factor authentication endpoint(User-scoped)")
+ ).build();
+ }
+
+ private Mono deleteTotp(ServerRequest request) {
+ var totpDeleteRequestMono = request.bodyToMono(PasswordRequest.class)
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required")))
+ .doOnNext(
+ passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest"));
+
+ var twoFactorAuthSettings =
+ totpDeleteRequestMono.flatMap(passwordRequest -> getCurrentUser()
+ .filter(user -> {
+ var rawPassword = passwordRequest.getPassword();
+ var encodedPassword = user.getSpec().getPassword();
+ return this.passwordEncoder.matches(rawPassword, encodedPassword);
+ })
+ .switchIfEmpty(
+ Mono.error(() -> new ServerWebInputException("Invalid password")))
+ .doOnNext(user -> {
+ var spec = user.getSpec();
+ spec.setTotpEncryptedSecret(null);
+ })
+ .flatMap(client::update)
+ .map(TwoFactorUtils::getTwoFactorAuthSettings));
+ return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class);
+ }
+
+ @Data
+ public static class PasswordRequest {
+
+ @NotBlank
+ private String password;
+
+ }
+
+ private Mono disableTwoFactor(ServerRequest request) {
+ return toggleTwoFactor(request, false);
+ }
+
+ private Mono enableTwoFactor(ServerRequest request) {
+ return toggleTwoFactor(request, true);
+ }
+
+ private Mono toggleTwoFactor(ServerRequest request, boolean enabled) {
+ return request.bodyToMono(PasswordRequest.class)
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required")))
+ .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest"))
+ .flatMap(passwordRequest -> getCurrentUser()
+ .filter(user -> {
+ var encodedPassword = user.getSpec().getPassword();
+ var rawPassword = passwordRequest.getPassword();
+ return passwordEncoder.matches(rawPassword, encodedPassword);
+ })
+ .switchIfEmpty(
+ Mono.error(() -> new ServerWebInputException("Invalid password")))
+ .doOnNext(user -> user.getSpec().setTwoFactorAuthEnabled(enabled))
+ .flatMap(client::update)
+ .map(TwoFactorUtils::getTwoFactorAuthSettings))
+ .flatMap(twoFactorAuthSettings -> ServerResponse.ok().bodyValue(twoFactorAuthSettings));
+ }
+
+ private Mono getTotpAuthLink(ServerRequest request) {
+ var authLinkResponse = getCurrentUser()
+ .map(user -> {
+ var username = user.getMetadata().getName();
+ var url = externalUrl.getURL(request.exchange().getRequest());
+ var authority = url.getAuthority();
+ var authKeyId = username + ":" + authority;
+ var rawSecret = twoFactorAuthService.generateTotpSecret();
+ var authLink = UriComponentsBuilder.fromUriString("otpauth://totp")
+ .path(authKeyId)
+ .queryParam("secret", rawSecret)
+ .queryParam("digits", 6)
+ .build().toUri();
+ var authLinkResp = new TotpAuthLinkResponse();
+ authLinkResp.setAuthLink(authLink);
+ authLinkResp.setRawSecret(rawSecret);
+ return authLinkResp;
+ });
+
+ return ServerResponse.ok().body(authLinkResponse, TotpAuthLinkResponse.class);
+ }
+
+ @Data
+ public static class TotpAuthLinkResponse {
+
+ /**
+ * QR Code with base64 encoded.
+ */
+ private URI authLink;
+
+ private String rawSecret;
+ }
+
+ private Mono configureTotp(ServerRequest request) {
+ var totpRequestMono = request.bodyToMono(TotpRequest.class)
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")))
+ .doOnNext(totpRequest -> this.validateRequest(totpRequest, "totp"));
+
+ var configuredUser = totpRequestMono.flatMap(totpRequest -> {
+ // validate password
+ return getCurrentUser()
+ .filter(user -> {
+ var encodedPassword = user.getSpec().getPassword();
+ var rawPassword = totpRequest.getPassword();
+ return passwordEncoder.matches(rawPassword, encodedPassword);
+ })
+ .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Invalid password")))
+ .doOnNext(user -> {
+ // TimeBasedOneTimePasswordUtil.
+ var rawSecret = totpRequest.getSecret();
+ int code;
+ try {
+ code = Integer.parseInt(totpRequest.getCode());
+ } catch (NumberFormatException e) {
+ throw new ServerWebInputException("Invalid code");
+ }
+ var validated = twoFactorAuthService.validateTotp(rawSecret, code);
+ if (!validated) {
+ throw new ServerWebInputException("Invalid secret or code");
+ }
+ var encryptedSecret = twoFactorAuthService.encryptSecret(rawSecret);
+ user.getSpec().setTotpEncryptedSecret(encryptedSecret);
+ })
+ .flatMap(client::update);
+ });
+
+ var twoFactorAuthSettings =
+ configuredUser.map(TwoFactorUtils::getTwoFactorAuthSettings);
+
+ return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class);
+ }
+
+ private void validateRequest(Object target, String name) {
+ var errors = new BeanPropertyBindingResult(target, name);
+ validator.validate(target, errors);
+ if (errors.hasErrors()) {
+ throw new RequestBodyValidationException(errors);
+ }
+ }
+
+ @Data
+ public static class TotpRequest {
+
+ @NotBlank
+ private String secret;
+
+ @NotNull
+ private String code;
+
+ @NotBlank
+ private String password;
+
+ }
+
+ private Mono getTwoFactorSettings(ServerRequest request) {
+ return getCurrentUser()
+ .map(TwoFactorUtils::getTwoFactorAuthSettings)
+ .flatMap(settings -> ServerResponse.ok().bodyValue(settings));
+ }
+
+ private Mono getCurrentUser() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .filter(TwoFactorAuthEndpoint::isAuthenticatedUser)
+ .switchIfEmpty(Mono.error(AccessDeniedException::new))
+ .map(Authentication::getName)
+ .flatMap(userService::getUser);
+ }
+
+ private static boolean isAuthenticatedUser(Authentication authentication) {
+ return authentication != null && !(authentication instanceof AnonymousAuthenticationToken);
+ }
+
+ @Override
+ public GroupVersion groupVersion() {
+ return GroupVersion.parseAPIVersion("api.security.halo.run/v1alpha1");
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthService.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthService.java
new file mode 100644
index 00000000000..cad756a65d7
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthService.java
@@ -0,0 +1,13 @@
+package run.halo.app.security.authentication.mfa;
+
+public interface TwoFactorAuthService {
+
+ boolean validateTotp(String rawSecret, int code);
+
+ String generateTotpSecret();
+
+ String encryptSecret(String rawSecret);
+
+ String decryptSecret(String encryptedSecret);
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthSettings.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthSettings.java
new file mode 100644
index 00000000000..d9a8dc08122
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorAuthSettings.java
@@ -0,0 +1,22 @@
+package run.halo.app.security.authentication.mfa;
+
+import lombok.Data;
+
+@Data
+public class TwoFactorAuthSettings {
+
+ private boolean enabled;
+
+ private boolean emailVerified;
+
+ private boolean totpConfigured;
+
+ /**
+ * Check if 2FA is available.
+ *
+ * @return true if 2FA is enabled and configured, false otherwise.
+ */
+ public boolean isAvailable() {
+ return enabled && (emailVerified || totpConfigured);
+ }
+}
diff --git a/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorUtils.java b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorUtils.java
new file mode 100644
index 00000000000..cddb13dddd2
--- /dev/null
+++ b/application/src/main/java/run/halo/app/security/authentication/mfa/TwoFactorUtils.java
@@ -0,0 +1,23 @@
+package run.halo.app.security.authentication.mfa;
+
+import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
+
+import org.apache.commons.lang3.StringUtils;
+import run.halo.app.core.extension.User;
+
+public enum TwoFactorUtils {
+ ;
+
+ public static TwoFactorAuthSettings getTwoFactorAuthSettings(User user) {
+ var spec = user.getSpec();
+ var tfaEnabled = defaultIfNull(spec.getTwoFactorAuthEnabled(), false);
+ var emailVerified = spec.isEmailVerified();
+ var totpEncryptedSecret = spec.getTotpEncryptedSecret();
+ var settings = new TwoFactorAuthSettings();
+ settings.setEnabled(tfaEnabled);
+ settings.setEmailVerified(emailVerified);
+ settings.setTotpConfigured(StringUtils.isNotBlank(totpEncryptedSecret));
+ return settings;
+ }
+
+}
diff --git a/application/src/main/java/run/halo/app/security/authorization/Attributes.java b/application/src/main/java/run/halo/app/security/authorization/Attributes.java
index c4a0758f7e4..a68317ba765 100644
--- a/application/src/main/java/run/halo/app/security/authorization/Attributes.java
+++ b/application/src/main/java/run/halo/app/security/authorization/Attributes.java
@@ -1,6 +1,6 @@
package run.halo.app.security.authorization;
-import org.springframework.security.core.userdetails.UserDetails;
+import java.security.Principal;
/**
* Attributes is used by an Authorizer to get information about a request
@@ -13,7 +13,7 @@ public interface Attributes {
/**
* @return the UserDetails object to authorize
*/
- UserDetails getUser();
+ Principal getPrincipal();
/**
* @return the verb associated with API requests(this includes get, list,
diff --git a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java
index 9ac98690632..3fb6833671f 100644
--- a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java
+++ b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java
@@ -1,6 +1,6 @@
package run.halo.app.security.authorization;
-import org.springframework.security.core.userdetails.UserDetails;
+import java.security.Principal;
/**
* @author guqing
@@ -8,16 +8,16 @@
*/
public class AttributesRecord implements Attributes {
private final RequestInfo requestInfo;
- private final UserDetails user;
+ private final Principal principal;
- public AttributesRecord(UserDetails user, RequestInfo requestInfo) {
+ public AttributesRecord(Principal principal, RequestInfo requestInfo) {
this.requestInfo = requestInfo;
- this.user = user;
+ this.principal = principal;
}
@Override
- public UserDetails getUser() {
- return this.user;
+ public Principal getPrincipal() {
+ return this.principal;
}
@Override
diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java
index b00fa310d0d..fce28a6778d 100644
--- a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java
+++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java
@@ -20,6 +20,10 @@ public enum AuthorityUtils {
public static final String SUPER_ROLE_NAME = "super-role";
+ public static final String AUTHENTICATED_ROLE_NAME = "authenticated";
+
+ public static final String ANONYMOUS_ROLE_NAME = "anonymous";
+
/**
* Converts an array of GrantedAuthority objects to a role set.
*
diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java
index 57ebf344247..cec9a5e0380 100644
--- a/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java
+++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java
@@ -1,6 +1,6 @@
package run.halo.app.security.authorization;
-import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.Authentication;
import reactor.core.publisher.Mono;
/**
@@ -9,5 +9,5 @@
*/
public interface AuthorizationRuleResolver {
- Mono visitRules(UserDetails user, RequestInfo requestInfo);
+ Mono visitRules(Authentication authentication, RequestInfo requestInfo);
}
diff --git a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java
index e40f2554bda..0ff51ae8633 100644
--- a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java
+++ b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java
@@ -1,18 +1,17 @@
package run.halo.app.security.authorization;
-import java.util.HashSet;
+import java.util.Collection;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
-import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.util.Assert;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
-import run.halo.app.core.extension.service.DefaultRoleBindingService;
-import run.halo.app.core.extension.service.RoleBindingService;
import run.halo.app.core.extension.service.RoleService;
-import run.halo.app.infra.AnonymousUserConst;
/**
* @author guqing
@@ -21,32 +20,24 @@
@Data
@Slf4j
public class DefaultRuleResolver implements AuthorizationRuleResolver {
- private static final String AUTHENTICATED_ROLE = "authenticated";
- private RoleService roleService;
- private RoleBindingService roleBindingService;
+ private RoleService roleService;
public DefaultRuleResolver(RoleService roleService) {
this.roleService = roleService;
- this.roleBindingService = new DefaultRoleBindingService();
}
@Override
- public Mono visitRules(UserDetails user, RequestInfo requestInfo) {
- var roleNamesImmutable = roleBindingService.listBoundRoleNames(user.getAuthorities());
- var roleNames = new HashSet<>(roleNamesImmutable);
- if (!AnonymousUserConst.PRINCIPAL.equals(user.getUsername())) {
- roleNames.add(AUTHENTICATED_ROLE);
- roleNames.add(AnonymousUserConst.Role);
- }
-
- var record = new AttributesRecord(user, requestInfo);
+ public Mono visitRules(Authentication authentication,
+ RequestInfo requestInfo) {
+ var roleNames = listBoundRoleNames(authentication.getAuthorities());
+ var record = new AttributesRecord(authentication, requestInfo);
var visitor = new AuthorizingVisitor(record);
// If the request is an userspace scoped request,
// then we should check whether the user is the owner of the userspace.
if (StringUtils.isNotBlank(requestInfo.getUserspace())) {
- if (!user.getUsername().equals(requestInfo.getUserspace())) {
+ if (!authentication.getName().equals(requestInfo.getUserspace())) {
return Mono.fromSupplier(() -> {
visitor.visit(null, null, null);
return visitor;
@@ -63,7 +54,7 @@ var record = new AttributesRecord(user, requestInfo);
}
String roleName = role.getMetadata().getName();
var rules = role.getRules();
- var source = roleBindingDescriber(roleName, user.getUsername());
+ var source = roleBindingDescriber(roleName, authentication.getName());
for (var rule : rules) {
if (!visitor.visit(source, rule, null)) {
stopVisiting.set(true);
@@ -73,7 +64,7 @@ var record = new AttributesRecord(user, requestInfo);
})
.takeUntil(item -> stopVisiting.get())
.onErrorResume(t -> visitor.visit(null, null, t), t -> {
- log.warn("Error occurred when visiting rules", t);
+ log.error("Error occurred when visiting rules", t);
//Do nothing here
return Mono.empty();
})
@@ -84,8 +75,15 @@ String roleBindingDescriber(String roleName, String subject) {
return String.format("Binding role [%s] to [%s]", roleName, subject);
}
- public void setRoleBindingService(RoleBindingService roleBindingService) {
- Assert.notNull(roleBindingService, "The roleBindingLister must not be null.");
- this.roleBindingService = roleBindingService;
+ private static Set listBoundRoleNames(
+ Collection extends GrantedAuthority> authorities) {
+ return authorities.stream()
+ .map(GrantedAuthority::getAuthority)
+ .map(authority -> {
+ authority = StringUtils.removeStart(authority, AuthorityUtils.SCOPE_PREFIX);
+ authority = StringUtils.removeStart(authority, AuthorityUtils.ROLE_PREFIX);
+ return authority;
+ })
+ .collect(Collectors.toSet());
}
}
diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
index 850ccc7516f..f450af7c475 100644
--- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
+++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java
@@ -6,17 +6,13 @@
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
-import org.springframework.security.core.userdetails.User;
-import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.server.authorization.AuthorizationContext;
-import org.springframework.util.Assert;
-import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.service.RoleService;
@Slf4j
public class RequestInfoAuthorizationManager
- implements ReactiveAuthorizationManager {
+ implements ReactiveAuthorizationManager {
private final AuthorizationRuleResolver ruleResolver;
@@ -26,41 +22,24 @@ public RequestInfoAuthorizationManager(RoleService roleService) {
@Override
public Mono check(Mono authentication,
- AuthorizationContext context) {
+ AuthorizationContext context) {
ServerHttpRequest request = context.getExchange().getRequest();
RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
- return authentication.flatMap(auth -> {
- var userDetails = this.createUserDetails(auth);
- return this.ruleResolver.visitRules(userDetails, requestInfo)
- .map(visitor -> {
- if (!visitor.isAllowed()) {
- showErrorMessage(visitor.getErrors());
- return new AuthorizationDecision(false);
- }
- return new AuthorizationDecision(isGranted(auth));
- });
- });
+ return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo)
+ .doOnNext(visitor -> showErrorMessage(visitor.getErrors()))
+ .filter(AuthorizingVisitor::isAllowed)
+ .map(visitor -> new AuthorizationDecision(isGranted(auth)))
+ .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))));
}
private boolean isGranted(Authentication authentication) {
return authentication != null && authentication.isAuthenticated();
}
- private UserDetails createUserDetails(Authentication authentication) {
- Assert.notNull(authentication, "The authentication must not be null.");
- return User.withUsername(authentication.getName())
- .authorities(authentication.getAuthorities())
- .password("")
- .build();
- }
-
private void showErrorMessage(List errors) {
- if (CollectionUtils.isEmpty(errors)) {
- return;
- }
- for (Throwable error : errors) {
- log.error("Access decision error: ", error);
+ if (errors != null) {
+ errors.forEach(error -> log.error("Access decision error", error));
}
}
diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml
index fb3856d3924..03055e067d2 100644
--- a/application/src/main/resources/extensions/role-template-authenticated.yaml
+++ b/application/src/main/resources/extensions/role-template-authenticated.yaml
@@ -14,6 +14,7 @@ metadata:
"role-template-stats",
"role-template-annotation-setting",
"role-template-manage-own-pat",
+ "role-template-manage-own-authentications",
"role-template-user-notification"
]
rules:
@@ -115,6 +116,19 @@ rules:
- apiGroups: [ "api.security.halo.run" ]
resources: [ "personalaccesstokens/actions" ]
verbs: [ "update" ]
+
+---
+apiVersion: v1alpha1
+kind: "Role"
+metadata:
+ name: role-template-manage-own-authentications
+ labels:
+ halo.run/role-template: "true"
+ halo.run/hidden: "true"
+rules:
+ - apiGroups: [ "api.security.halo.run" ]
+ resources: [ "authentications", "authentications/totp" ]
+ verbs: [ "*" ]
---
apiVersion: v1alpha1
kind: Role
diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java
index 179aba87ebf..af2dc8c7b3b 100644
--- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java
+++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java
@@ -532,28 +532,7 @@ void shouldUploadSuccessfully() {
.exchange()
.expectStatus()
.isOk()
- .expectBody()
- .json("""
- {
- "spec":{
- "displayName":"Faker",
- "avatar":"fake-avatar.png",
- "email":"hi@halo.run",
- "password":"fake-password",
- "bio":"Fake bio"
- },
- "status":null,
- "apiVersion":"v1alpha1",
- "kind":"User",
- "metadata":{
- "name":"fake-user",
- "annotations":{
- "halo.run/avatar-attachment-name":
- "fake-attachment"
- }
- }
- }
- """);
+ .expectBody(User.class).isEqualTo(currentUser);
verify(client).get(User.class, "fake-user");
verify(client).update(currentUser);
diff --git a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java
index a9a5d281434..975a71dbbeb 100644
--- a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java
+++ b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java
@@ -5,16 +5,15 @@
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.springframework.security.core.authority.AuthorityUtils.authorityListToSet;
-import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.AuthenticationException;
-import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import reactor.core.publisher.Flux;
@@ -101,11 +100,9 @@ void shouldFindUserDetailsByExistingUsername() {
.assertNext(gotUser -> {
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
- assertEquals(List.of("ROLE_fake-role"),
- gotUser.getAuthorities()
- .stream()
- .map(GrantedAuthority::getAuthority)
- .collect(Collectors.toList()));
+ assertEquals(
+ Set.of("ROLE_fake-role", "ROLE_authenticated", "ROLE_anonymous"),
+ authorityListToSet(gotUser.getAuthorities()));
})
.verifyComplete();
}
@@ -133,7 +130,11 @@ void shouldFindUserDetailsByExistingUsernameButKindOfRoleRefIsNotRole() {
.assertNext(gotUser -> {
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
- assertEquals(0, gotUser.getAuthorities().size());
+ assertEquals(2, gotUser.getAuthorities().size());
+ assertEquals(
+ Set.of("ROLE_anonymous", "ROLE_authenticated"),
+ authorityListToSet(gotUser.getAuthorities())
+ );
})
.verifyComplete();
}
@@ -155,7 +156,9 @@ void shouldFindUserDetailsByExistingUsernameButWithoutAnyRoles() {
.assertNext(gotUser -> {
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
- assertEquals(0, gotUser.getAuthorities().size());
+ assertEquals(
+ Set.of("ROLE_anonymous", "ROLE_authenticated"),
+ authorityListToSet(gotUser.getAuthorities()));
})
.verifyComplete();
}
diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java
deleted file mode 100644
index 890fcf5202a..00000000000
--- a/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package run.halo.app.security.authorization;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
-import org.springframework.security.core.context.SecurityContextHolder;
-import run.halo.app.core.extension.service.DefaultRoleBindingService;
-
-/**
- * Tests for {@link DefaultRoleBindingService}.
- *
- * @author guqing
- * @since 2.0.0
- */
-public class DefaultRoleBindingServiceTest {
-
- private DefaultRoleBindingService roleBindingLister;
-
- @BeforeEach
- void setUp() {
- roleBindingLister = new DefaultRoleBindingService();
- }
-
- @AfterEach
- void cleanUp() {
- SecurityContextHolder.clearContext();
- }
-
- @Test
- void listWhenAuthorizedRoles() {
- var authorities = List.of(
- new SimpleGrantedAuthority("readFake"),
- new SimpleGrantedAuthority("fake.read"),
- new SimpleGrantedAuthority("ROLE_role.fake.read"),
- new SimpleGrantedAuthority("SCOPE_scope.fake.read"),
- new SimpleGrantedAuthority("SCOPE_ROLE_scope.role.fake.read"));
-
- Set roleBindings = roleBindingLister.listBoundRoleNames(authorities);
- assertThat(roleBindings).isNotNull();
- assertThat(roleBindings).isEqualTo(Set.of(
- "readFake",
- "fake.read",
- "role.fake.read",
- "scope.fake.read",
- "scope.role.fake.read"));
- }
-
- @Test
- void listWhenUnauthorizedThenEmpty() {
- var roleBindings = roleBindingLister.listBoundRoleNames(Collections.emptyList());
- assertThat(roleBindings).isEmpty();
- }
-}
diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java
index 7785c91fdd2..a311bac5deb 100644
--- a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java
+++ b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java
@@ -5,6 +5,7 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method;
+import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated;
import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList;
import java.util.List;
@@ -34,29 +35,33 @@ class DefaultRuleResolverTest {
@Test
void visitRules() {
- when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost")))
+ when(roleService.listDependenciesFlux(Set.of("ruleReadPost")))
.thenReturn(Flux.just(mockRole()));
var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost"));
+ var authentication = authenticated(fakeUser, fakeUser.getPassword(),
+ fakeUser.getAuthorities());
+
var cases = getRequestResolveCases();
cases.forEach(requestResolveCase -> {
var httpMethod = HttpMethod.valueOf(requestResolveCase.method);
var request = method(httpMethod, requestResolveCase.url).build();
var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
- StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo))
+ StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo))
.assertNext(
visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed()))
.verifyComplete();
});
- verify(roleService, times(cases.size())).listDependenciesFlux(
- Set.of("authenticated", "anonymous", "ruleReadPost"));
+ verify(roleService, times(cases.size())).listDependenciesFlux(Set.of("ruleReadPost"));
}
@Test
void visitRulesForUserspaceScope() {
- when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost")))
+ when(roleService.listDependenciesFlux(Set.of("ruleReadPost")))
.thenReturn(Flux.just(mockRole()));
var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost"));
+ var authentication =
+ authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities());
var cases = List.of(
new RequestResolveCase("/api/v1/categories", "POST", true),
new RequestResolveCase("/api/v1/categories", "DELETE", true),
@@ -71,7 +76,7 @@ void visitRulesForUserspaceScope() {
var httpMethod = HttpMethod.valueOf(requestResolveCase.method);
var request = method(httpMethod, requestResolveCase.url).build();
var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
- StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo))
+ StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo))
.assertNext(
visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed()))
.verifyComplete();
diff --git a/console/console-src/modules/system/users/Mfa.vue b/console/console-src/modules/system/users/Mfa.vue
new file mode 100644
index 00000000000..7da425dda71
--- /dev/null
+++ b/console/console-src/modules/system/users/Mfa.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 验证
+
+
+
+
+
+
+
diff --git a/console/console-src/modules/system/users/module.ts b/console/console-src/modules/system/users/module.ts
index 9f260715ca0..cba5b88f140 100644
--- a/console/console-src/modules/system/users/module.ts
+++ b/console/console-src/modules/system/users/module.ts
@@ -4,6 +4,7 @@ import UserStatsWidget from "./widgets/UserStatsWidget.vue";
import UserList from "./UserList.vue";
import UserDetail from "./UserDetail.vue";
import Login from "./Login.vue";
+import Mfa from "./Mfa.vue";
import { IconUserSettings } from "@halo-dev/components";
import { markRaw } from "vue";
import Binding from "./Binding.vue";
@@ -23,6 +24,14 @@ export default definePlugin({
title: "core.login.title",
},
},
+ {
+ path: "/login/mfa",
+ name: "Mfa",
+ component: Mfa,
+ meta: {
+ title: "Multi-Factor Authentication",
+ },
+ },
{
path: "/binding/:provider",
name: "Binding",
diff --git a/console/console-src/router/guards/auth-check.ts b/console/console-src/router/guards/auth-check.ts
index 12950f3070d..14c2073c1ae 100644
--- a/console/console-src/router/guards/auth-check.ts
+++ b/console/console-src/router/guards/auth-check.ts
@@ -2,7 +2,7 @@ import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import type { Router } from "vue-router";
-const whiteList = ["Setup", "Login", "Binding", "ResetPassword"];
+const whiteList = ["Setup", "Login", "Binding", "ResetPassword", "Mfa"];
export function setupAuthCheckGuard(router: Router) {
router.beforeEach((to, from, next) => {
diff --git a/console/package.json b/console/package.json
index 77b70ba3527..c96d3cfafaa 100644
--- a/console/package.json
+++ b/console/package.json
@@ -71,6 +71,7 @@
"@uppy/xhr-upload": "^3.6.0",
"@vueuse/components": "^10.3.0",
"@vueuse/core": "^10.3.0",
+ "@vueuse/integrations": "^10.5.0",
"@vueuse/router": "^10.3.0",
"@vueuse/shared": "^10.3.0",
"axios": "^0.27.2",
@@ -89,6 +90,7 @@
"path-browserify": "^1.0.1",
"pinia": "^2.1.6",
"pretty-bytes": "^6.0.0",
+ "qrcode": "^1.5.3",
"qs": "^6.11.1",
"short-unique-id": "^5.0.2",
"transliteration": "^2.3.5",
diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES
index b1676ea6134..ced5501674d 100644
--- a/console/packages/api-client/src/.openapi-generator/FILES
+++ b/console/packages/api-client/src/.openapi-generator/FILES
@@ -29,6 +29,7 @@ api/api-notification-halo-run-v1alpha1-notification-api.ts
api/api-notification-halo-run-v1alpha1-notifier-api.ts
api/api-notification-halo-run-v1alpha1-subscription-api.ts
api/api-plugin-halo-run-v1alpha1-plugin-api.ts
+api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts
api/api-security-halo-run-v1alpha1-personal-access-token-api.ts
api/auth-halo-run-v1alpha1-auth-provider-api.ts
api/auth-halo-run-v1alpha1-user-connection-api.ts
@@ -186,6 +187,7 @@ models/notifier-descriptor.ts
models/notifier-info.ts
models/notifier-setting-ref.ts
models/owner-info.ts
+models/password-request.ts
models/password-reset-email-request.ts
models/pat-spec.ts
models/personal-access-token-list.ts
@@ -283,6 +285,9 @@ models/theme-list.ts
models/theme-spec.ts
models/theme-status.ts
models/theme.ts
+models/totp-auth-link-response.ts
+models/totp-request.ts
+models/two-factor-auth-settings.ts
models/upgrade-from-uri-request.ts
models/user-connection-list.ts
models/user-connection-spec.ts
diff --git a/console/packages/api-client/src/api.ts b/console/packages/api-client/src/api.ts
index fcdd3ce669c..cd7dcb33357 100644
--- a/console/packages/api-client/src/api.ts
+++ b/console/packages/api-client/src/api.ts
@@ -40,6 +40,7 @@ export * from "./api/api-notification-halo-run-v1alpha1-notification-api";
export * from "./api/api-notification-halo-run-v1alpha1-notifier-api";
export * from "./api/api-notification-halo-run-v1alpha1-subscription-api";
export * from "./api/api-plugin-halo-run-v1alpha1-plugin-api";
+export * from "./api/api-security-halo-run-v1alpha1-authentication-two-factor-api";
export * from "./api/api-security-halo-run-v1alpha1-personal-access-token-api";
export * from "./api/auth-halo-run-v1alpha1-auth-provider-api";
export * from "./api/auth-halo-run-v1alpha1-user-connection-api";
diff --git a/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts b/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts
new file mode 100644
index 00000000000..24cacfd9491
--- /dev/null
+++ b/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts
@@ -0,0 +1,795 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo Next API
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+import type { Configuration } from "../configuration";
+import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
+import globalAxios from "axios";
+// Some imports not used depending on template conditions
+// @ts-ignore
+import {
+ DUMMY_BASE_URL,
+ assertParamExists,
+ setApiKeyToObject,
+ setBasicAuthToObject,
+ setBearerAuthToObject,
+ setOAuthToObject,
+ setSearchParams,
+ serializeDataIfNeeded,
+ toPathString,
+ createRequestFunction,
+} from "../common";
+// @ts-ignore
+import {
+ BASE_PATH,
+ COLLECTION_FORMATS,
+ RequestArgs,
+ BaseAPI,
+ RequiredError,
+} from "../base";
+// @ts-ignore
+import { PasswordRequest } from "../models";
+// @ts-ignore
+import { TotpAuthLinkResponse } from "../models";
+// @ts-ignore
+import { TotpRequest } from "../models";
+// @ts-ignore
+import { TwoFactorAuthSettings } from "../models";
+/**
+ * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - axios parameter creator
+ * @export
+ */
+export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiAxiosParamCreator =
+ function (configuration?: Configuration) {
+ return {
+ /**
+ * Configure a TOTP
+ * @param {TotpRequest} [totpRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ configurerTotp: async (
+ totpRequest?: TotpRequest,
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "POST",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ localVarHeaderParameter["Content-Type"] = "application/json";
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+ localVarRequestOptions.data = serializeDataIfNeeded(
+ totpRequest,
+ localVarRequestOptions,
+ configuration
+ );
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ deleteTotp: async (
+ passwordRequest?: PasswordRequest,
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp/-`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "DELETE",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ localVarHeaderParameter["Content-Type"] = "application/json";
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+ localVarRequestOptions.data = serializeDataIfNeeded(
+ passwordRequest,
+ localVarRequestOptions,
+ configuration
+ );
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ * Disable Two-factor authentication
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ disableTwoFactor: async (
+ passwordRequest?: PasswordRequest,
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings/disabled`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "PUT",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ localVarHeaderParameter["Content-Type"] = "application/json";
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+ localVarRequestOptions.data = serializeDataIfNeeded(
+ passwordRequest,
+ localVarRequestOptions,
+ configuration
+ );
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ * Enable Two-factor authentication
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ enableTwoFactor: async (
+ passwordRequest?: PasswordRequest,
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings/enabled`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "PUT",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ localVarHeaderParameter["Content-Type"] = "application/json";
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+ localVarRequestOptions.data = serializeDataIfNeeded(
+ passwordRequest,
+ localVarRequestOptions,
+ configuration
+ );
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ * Get TOTP auth link, including secret
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getTotpAuthLink: async (
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp/auth-link`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "GET",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ * Get Two-factor authentication settings.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getTwoFactorAuthenticationSettings: async (
+ options: AxiosRequestConfig = {}
+ ): Promise => {
+ const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = {
+ method: "GET",
+ ...baseOptions,
+ ...options,
+ };
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication BasicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration);
+
+ // authentication BearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration);
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions =
+ baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {
+ ...localVarHeaderParameter,
+ ...headersFromBaseOptions,
+ ...options.headers,
+ };
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ };
+ };
+
+/**
+ * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - functional programming interface
+ * @export
+ */
+export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp = function (
+ configuration?: Configuration
+) {
+ const localVarAxiosParamCreator =
+ ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiAxiosParamCreator(
+ configuration
+ );
+ return {
+ /**
+ * Configure a TOTP
+ * @param {TotpRequest} [totpRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async configurerTotp(
+ totpRequest?: TotpRequest,
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.configurerTotp(
+ totpRequest,
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ /**
+ *
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async deleteTotp(
+ passwordRequest?: PasswordRequest,
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.deleteTotp(
+ passwordRequest,
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ /**
+ * Disable Two-factor authentication
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async disableTwoFactor(
+ passwordRequest?: PasswordRequest,
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs =
+ await localVarAxiosParamCreator.disableTwoFactor(
+ passwordRequest,
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ /**
+ * Enable Two-factor authentication
+ * @param {PasswordRequest} [passwordRequest]
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async enableTwoFactor(
+ passwordRequest?: PasswordRequest,
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.enableTwoFactor(
+ passwordRequest,
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ /**
+ * Get TOTP auth link, including secret
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async getTotpAuthLink(
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.getTotpAuthLink(
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ /**
+ * Get Two-factor authentication settings.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async getTwoFactorAuthenticationSettings(
+ options?: AxiosRequestConfig
+ ): Promise<
+ (
+ axios?: AxiosInstance,
+ basePath?: string
+ ) => AxiosPromise
+ > {
+ const localVarAxiosArgs =
+ await localVarAxiosParamCreator.getTwoFactorAuthenticationSettings(
+ options
+ );
+ return createRequestFunction(
+ localVarAxiosArgs,
+ globalAxios,
+ BASE_PATH,
+ configuration
+ );
+ },
+ };
+};
+
+/**
+ * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - factory interface
+ * @export
+ */
+export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFactory =
+ function (
+ configuration?: Configuration,
+ basePath?: string,
+ axios?: AxiosInstance
+ ) {
+ const localVarFp =
+ ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(configuration);
+ return {
+ /**
+ * Configure a TOTP
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ configurerTotp(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest = {},
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .configurerTotp(requestParameters.totpRequest, options)
+ .then((request) => request(axios, basePath));
+ },
+ /**
+ *
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ deleteTotp(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest = {},
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .deleteTotp(requestParameters.passwordRequest, options)
+ .then((request) => request(axios, basePath));
+ },
+ /**
+ * Disable Two-factor authentication
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ disableTwoFactor(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest = {},
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .disableTwoFactor(requestParameters.passwordRequest, options)
+ .then((request) => request(axios, basePath));
+ },
+ /**
+ * Enable Two-factor authentication
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ enableTwoFactor(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest = {},
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .enableTwoFactor(requestParameters.passwordRequest, options)
+ .then((request) => request(axios, basePath));
+ },
+ /**
+ * Get TOTP auth link, including secret
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getTotpAuthLink(
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .getTotpAuthLink(options)
+ .then((request) => request(axios, basePath));
+ },
+ /**
+ * Get Two-factor authentication settings.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ getTwoFactorAuthenticationSettings(
+ options?: AxiosRequestConfig
+ ): AxiosPromise {
+ return localVarFp
+ .getTwoFactorAuthenticationSettings(options)
+ .then((request) => request(axios, basePath));
+ },
+ };
+ };
+
+/**
+ * Request parameters for configurerTotp operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi.
+ * @export
+ * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest
+ */
+export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest {
+ /**
+ *
+ * @type {TotpRequest}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotp
+ */
+ readonly totpRequest?: TotpRequest;
+}
+
+/**
+ * Request parameters for deleteTotp operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi.
+ * @export
+ * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest
+ */
+export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest {
+ /**
+ *
+ * @type {PasswordRequest}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotp
+ */
+ readonly passwordRequest?: PasswordRequest;
+}
+
+/**
+ * Request parameters for disableTwoFactor operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi.
+ * @export
+ * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest
+ */
+export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest {
+ /**
+ *
+ * @type {PasswordRequest}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactor
+ */
+ readonly passwordRequest?: PasswordRequest;
+}
+
+/**
+ * Request parameters for enableTwoFactor operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi.
+ * @export
+ * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest
+ */
+export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest {
+ /**
+ *
+ * @type {PasswordRequest}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactor
+ */
+ readonly passwordRequest?: PasswordRequest;
+}
+
+/**
+ * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - object-oriented interface
+ * @export
+ * @class ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ * @extends {BaseAPI}
+ */
+export class ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi extends BaseAPI {
+ /**
+ * Configure a TOTP
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public configurerTotp(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest = {},
+ options?: AxiosRequestConfig
+ ) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .configurerTotp(requestParameters.totpRequest, options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ *
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public deleteTotp(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest = {},
+ options?: AxiosRequestConfig
+ ) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .deleteTotp(requestParameters.passwordRequest, options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ * Disable Two-factor authentication
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public disableTwoFactor(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest = {},
+ options?: AxiosRequestConfig
+ ) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .disableTwoFactor(requestParameters.passwordRequest, options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ * Enable Two-factor authentication
+ * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public enableTwoFactor(
+ requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest = {},
+ options?: AxiosRequestConfig
+ ) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .enableTwoFactor(requestParameters.passwordRequest, options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ * Get TOTP auth link, including secret
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public getTotpAuthLink(options?: AxiosRequestConfig) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .getTotpAuthLink(options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+
+ /**
+ * Get Two-factor authentication settings.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi
+ */
+ public getTwoFactorAuthenticationSettings(options?: AxiosRequestConfig) {
+ return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(
+ this.configuration
+ )
+ .getTwoFactorAuthenticationSettings(options)
+ .then((request) => request(this.axios, this.basePath));
+ }
+}
diff --git a/console/packages/api-client/src/models/index.ts b/console/packages/api-client/src/models/index.ts
index f1bc6a13005..d0c57009343 100644
--- a/console/packages/api-client/src/models/index.ts
+++ b/console/packages/api-client/src/models/index.ts
@@ -106,6 +106,7 @@ export * from "./notifier-descriptor-spec";
export * from "./notifier-info";
export * from "./notifier-setting-ref";
export * from "./owner-info";
+export * from "./password-request";
export * from "./password-reset-email-request";
export * from "./pat-spec";
export * from "./personal-access-token";
@@ -203,6 +204,9 @@ export * from "./theme";
export * from "./theme-list";
export * from "./theme-spec";
export * from "./theme-status";
+export * from "./totp-auth-link-response";
+export * from "./totp-request";
+export * from "./two-factor-auth-settings";
export * from "./upgrade-from-uri-request";
export * from "./user";
export * from "./user-connection";
diff --git a/console/packages/api-client/src/models/password-request.ts b/console/packages/api-client/src/models/password-request.ts
new file mode 100644
index 00000000000..04247c71dcc
--- /dev/null
+++ b/console/packages/api-client/src/models/password-request.ts
@@ -0,0 +1,27 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo Next API
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ * @interface PasswordRequest
+ */
+export interface PasswordRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof PasswordRequest
+ */
+ password: string;
+}
diff --git a/console/packages/api-client/src/models/totp-auth-link-response.ts b/console/packages/api-client/src/models/totp-auth-link-response.ts
new file mode 100644
index 00000000000..094ec0c5e0f
--- /dev/null
+++ b/console/packages/api-client/src/models/totp-auth-link-response.ts
@@ -0,0 +1,33 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo Next API
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ * @interface TotpAuthLinkResponse
+ */
+export interface TotpAuthLinkResponse {
+ /**
+ *
+ * @type {string}
+ * @memberof TotpAuthLinkResponse
+ */
+ authLink?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TotpAuthLinkResponse
+ */
+ rawSecret?: string;
+}
diff --git a/console/packages/api-client/src/models/totp-request.ts b/console/packages/api-client/src/models/totp-request.ts
new file mode 100644
index 00000000000..51cf3f6ac83
--- /dev/null
+++ b/console/packages/api-client/src/models/totp-request.ts
@@ -0,0 +1,39 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo Next API
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ * @interface TotpRequest
+ */
+export interface TotpRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof TotpRequest
+ */
+ code: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TotpRequest
+ */
+ password: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TotpRequest
+ */
+ secret: string;
+}
diff --git a/console/packages/api-client/src/models/two-factor-auth-settings.ts b/console/packages/api-client/src/models/two-factor-auth-settings.ts
new file mode 100644
index 00000000000..7c5269f55a4
--- /dev/null
+++ b/console/packages/api-client/src/models/two-factor-auth-settings.ts
@@ -0,0 +1,45 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo Next API
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ * @interface TwoFactorAuthSettings
+ */
+export interface TwoFactorAuthSettings {
+ /**
+ *
+ * @type {boolean}
+ * @memberof TwoFactorAuthSettings
+ */
+ available?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof TwoFactorAuthSettings
+ */
+ emailVerified?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof TwoFactorAuthSettings
+ */
+ enabled?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof TwoFactorAuthSettings
+ */
+ totpConfigured?: boolean;
+}
diff --git a/console/packages/api-client/src/models/user-spec.ts b/console/packages/api-client/src/models/user-spec.ts
index c2c2d1efb97..8a55b40b5d5 100644
--- a/console/packages/api-client/src/models/user-spec.ts
+++ b/console/packages/api-client/src/models/user-spec.ts
@@ -78,6 +78,12 @@ export interface UserSpec {
* @memberof UserSpec
*/
registeredAt?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof UserSpec
+ */
+ totpEncryptedSecret?: string;
/**
*
* @type {boolean}
diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml
index f5195a7618a..9881ade70da 100644
--- a/console/pnpm-lock.yaml
+++ b/console/pnpm-lock.yaml
@@ -110,6 +110,9 @@ importers:
'@vueuse/core':
specifier: ^10.3.0
version: 10.3.0(vue@3.3.4)
+ '@vueuse/integrations':
+ specifier: ^10.5.0
+ version: 10.7.1(axios@0.27.2)(fuse.js@6.6.2)(qrcode@1.5.3)(vue@3.3.4)
'@vueuse/router':
specifier: ^10.3.0
version: 10.3.0(vue-router@4.2.4)(vue@3.3.4)
@@ -164,6 +167,9 @@ importers:
pretty-bytes:
specifier: ^6.0.0
version: 6.0.0
+ qrcode:
+ specifier: ^1.5.3
+ version: 1.5.3
qs:
specifier: ^6.11.1
version: 6.11.1
@@ -6519,6 +6525,10 @@ packages:
resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
dev: false
+ /@types/web-bluetooth@0.0.20:
+ resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+ dev: false
+
/@types/yargs-parser@21.0.3:
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
dev: true
@@ -7199,10 +7209,78 @@ packages:
- vue
dev: false
+ /@vueuse/core@10.7.1(vue@3.3.4):
+ resolution: {integrity: sha512-74mWHlaesJSWGp1ihg76vAnfVq9NTv1YT0SYhAQ6zwFNdBkkP+CKKJmVOEHcdSnLXCXYiL5e7MaewblfiYLP7g==}
+ dependencies:
+ '@types/web-bluetooth': 0.0.20
+ '@vueuse/metadata': 10.7.1
+ '@vueuse/shared': 10.7.1(vue@3.3.4)
+ vue-demi: 0.14.6(vue@3.3.4)
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - vue
+ dev: false
+
+ /@vueuse/integrations@10.7.1(axios@0.27.2)(fuse.js@6.6.2)(qrcode@1.5.3)(vue@3.3.4):
+ resolution: {integrity: sha512-cKo5LEeKVHdBRBtMTOrDPdR0YNtrmN9IBfdcnY2P3m5LHVrsD0xiHUtAH1WKjHQRIErZG6rJUa6GA4tWZt89Og==}
+ peerDependencies:
+ async-validator: '*'
+ axios: '*'
+ change-case: '*'
+ drauu: '*'
+ focus-trap: '*'
+ fuse.js: '*'
+ idb-keyval: '*'
+ jwt-decode: '*'
+ nprogress: '*'
+ qrcode: '*'
+ sortablejs: '*'
+ universal-cookie: '*'
+ peerDependenciesMeta:
+ async-validator:
+ optional: true
+ axios:
+ optional: true
+ change-case:
+ optional: true
+ drauu:
+ optional: true
+ focus-trap:
+ optional: true
+ fuse.js:
+ optional: true
+ idb-keyval:
+ optional: true
+ jwt-decode:
+ optional: true
+ nprogress:
+ optional: true
+ qrcode:
+ optional: true
+ sortablejs:
+ optional: true
+ universal-cookie:
+ optional: true
+ dependencies:
+ '@vueuse/core': 10.7.1(vue@3.3.4)
+ '@vueuse/shared': 10.7.1(vue@3.3.4)
+ axios: 0.27.2
+ fuse.js: 6.6.2
+ qrcode: 1.5.3
+ vue-demi: 0.14.6(vue@3.3.4)
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - vue
+ dev: false
+
/@vueuse/metadata@10.3.0:
resolution: {integrity: sha512-Ema3YhNOa4swDsV0V7CEY5JXvK19JI/o1szFO1iWxdFg3vhdFtCtSTP26PCvbUpnUtNHBY2wx5y3WDXND5Pvnw==}
dev: false
+ /@vueuse/metadata@10.7.1:
+ resolution: {integrity: sha512-jX8MbX5UX067DYVsbtrmKn6eG6KMcXxLRLlurGkZku5ZYT3vxgBjui2zajvUZ18QLIjrgBkFRsu7CqTAg18QFw==}
+ dev: false
+
/@vueuse/router@10.3.0(vue-router@4.2.4)(vue@3.3.4):
resolution: {integrity: sha512-WCx/BAxO0eInuOcyNRBxDLS16tnNqzdaR6/babg6AUgAIL0TCfmHBh46wJa6hhg+NMGjd6HzCaktxBasp+0c0A==}
peerDependencies:
@@ -7225,6 +7303,15 @@ packages:
- vue
dev: false
+ /@vueuse/shared@10.7.1(vue@3.3.4):
+ resolution: {integrity: sha512-v0jbRR31LSgRY/C5i5X279A/WQjD6/JsMzGa+eqt658oJ75IvQXAeONmwvEMrvJQKnRElq/frzBR7fhmWY5uLw==}
+ dependencies:
+ vue-demi: 0.14.6(vue@3.3.4)
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - vue
+ dev: false
+
/@webassemblyjs/ast@1.11.6:
resolution: {integrity: sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==}
dependencies:
@@ -8181,7 +8268,6 @@ packages:
/camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
- dev: true
/camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
@@ -8400,7 +8486,6 @@ packages:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
- dev: true
/cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -8978,7 +9063,6 @@ packages:
/decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
- dev: true
/decimal.js@10.4.2:
resolution: {integrity: sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==}
@@ -9192,6 +9276,10 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dev: true
+ /dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+ dev: false
+
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -9367,6 +9455,10 @@ packages:
engines: {node: '>= 4'}
dev: true
+ /encode-utf8@1.0.3:
+ resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
+ dev: false
+
/encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
@@ -10608,7 +10700,6 @@ packages:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
- dev: true
/find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
@@ -12579,7 +12670,6 @@ packages:
engines: {node: '>=8'}
dependencies:
p-locate: 4.1.0
- dev: true
/locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
@@ -13581,7 +13671,6 @@ packages:
engines: {node: '>=6'}
dependencies:
p-try: 2.2.0
- dev: true
/p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -13609,7 +13698,6 @@ packages:
engines: {node: '>=8'}
dependencies:
p-limit: 2.3.0
- dev: true
/p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
@@ -13655,7 +13743,6 @@ packages:
/p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
- dev: true
/pac-proxy-agent@7.0.1:
resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==}
@@ -13773,7 +13860,6 @@ packages:
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
- dev: true
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
@@ -13958,6 +14044,11 @@ packages:
pathe: 1.1.1
dev: true
+ /pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+ dev: false
+
/polished@4.2.2:
resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==}
engines: {node: '>=10'}
@@ -14583,6 +14674,17 @@ packages:
- utf-8-validate
dev: true
+ /qrcode@1.5.3:
+ resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+ dependencies:
+ dijkstrajs: 1.0.3
+ encode-utf8: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+ dev: false
+
/qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
@@ -14996,7 +15098,6 @@ packages:
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
- dev: true
/requireindex@1.2.0:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
@@ -15427,7 +15528,6 @@ packages:
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
- dev: true
/set-function-length@1.1.1:
resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
@@ -17288,6 +17388,21 @@ packages:
vue: 3.3.4
dev: false
+ /vue-demi@0.14.6(vue@3.3.4):
+ resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ peerDependencies:
+ '@vue/composition-api': ^1.0.0-rc.1
+ vue: ^3.0.0-0 || ^2.6.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+ dependencies:
+ vue: 3.3.4
+ dev: false
+
/vue-docgen-api@4.75.1(vue@3.3.4):
resolution: {integrity: sha512-MECZ3uExz+ssmhD/2XrFoQQs93y17IVO1KDYTp8nr6i9GNrk67AAto6QAtilW1H/pTDPMkQxJ7w/25ZIqVtfAA==}
peerDependencies:
@@ -17612,7 +17727,6 @@ packages:
/which-module@2.0.0:
resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
- dev: true
/which-pm@2.0.0:
resolution: {integrity: sha512-Lhs9Pmyph0p5n5Z3mVnN0yWcbQYUAD7rbQUiMsQxOJ3T57k7RFe35SUwWMf7dsbDZks1uOmw4AecB/JMDj3v/w==}
@@ -17851,7 +17965,6 @@ packages:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
- dev: true
/wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
@@ -17946,7 +18059,6 @@ packages:
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
- dev: true
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
@@ -17988,7 +18100,6 @@ packages:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
- dev: true
/yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
@@ -18014,7 +18125,6 @@ packages:
which-module: 2.0.0
y18n: 4.0.3
yargs-parser: 18.1.3
- dev: true
/yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
diff --git a/console/src/components/login/LoginForm.vue b/console/src/components/login/LoginForm.vue
index 4435b45251e..d6d80496e47 100644
--- a/console/src/components/login/LoginForm.vue
+++ b/console/src/components/login/LoginForm.vue
@@ -11,8 +11,11 @@ import { submitForm } from "@formkit/core";
import { JSEncrypt } from "jsencrypt";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
+import { ERROR_MFA_REQUIRED_TYPE } from "@/constants/error-types";
+import { useRouter } from "vue-router";
const { t } = useI18n();
+const router = useRouter();
withDefaults(
defineProps<{
@@ -68,6 +71,7 @@ const handleLogin = async () => {
withCredentials: true,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
+ "X-Requested-With": "XMLHttpRequest",
},
}
);
@@ -92,7 +96,16 @@ const handleLogin = async () => {
return;
}
- const { title: errorTitle, detail: errorDetail } = e.response?.data || {};
+ const {
+ title: errorTitle,
+ detail: errorDetail,
+ type: errorType,
+ } = e.response?.data || {};
+
+ if (errorType === ERROR_MFA_REQUIRED_TYPE) {
+ router.push({ name: "Mfa" });
+ return;
+ }
if (errorTitle || errorDetail) {
Toast.error(errorDetail || errorTitle);
diff --git a/console/src/constants/error-types.ts b/console/src/constants/error-types.ts
new file mode 100644
index 00000000000..fe6f5ca13c1
--- /dev/null
+++ b/console/src/constants/error-types.ts
@@ -0,0 +1 @@
+export const ERROR_MFA_REQUIRED_TYPE = "https://halo.run/probs/mfa-required";
diff --git a/console/src/locales/es.yaml b/console/src/locales/es.yaml
index afa2f7138b6..a77b1838b4d 100644
--- a/console/src/locales/es.yaml
+++ b/console/src/locales/es.yaml
@@ -1,1274 +1,1272 @@
-# core:
-# login:
-# title: Inicio de sesión
-# fields:
-# username:
-# placeholder: Usuario
-# password:
-# placeholder: Contraseña
-# operations:
-# submit:
-# toast_success: Inicio de sesión exitoso
-# toast_failed: Error en el inicio de sesión, nombre de usuario o contraseña incorrectos
-# toast_csrf: Token CSRF no válido, por favor inténtalo de nuevo
-# signup:
-# label: No tienes una cuenta
-# button: Registrarse ahora
-# return_login:
-# label: Ya tienes una cuenta
-# button: Iniciar sesión ahora
-# return_site: Volver a la página de inicio
-# button: Iniciar sesión
-# modal:
-# title: Volver a iniciar sesión
-# signup:
-# title: Registrarse
-# fields:
-# username:
-# placeholder: Nombre de usuario
-# display_name:
-# placeholder: Nombre para mostrar
-# password:
-# placeholder: Contraseña
-# password_confirm:
-# placeholder: Confirmar contraseña
-# operations:
-# submit:
-# button: Registrarse
-# toast_success: Registrado exitosamente
-# binding:
-# title: Vinculación de cuentas
-# common:
-# toast:
-# mounted: El método de inicio de sesión actual no está vinculado a una cuenta. Por favor, vincula o registra una nueva cuenta primero.
-# operations:
-# login_and_bind:
-# button: Iniciar sesión y vincular
-# signup_and_bind:
-# button: Registrarse y vincular
-# bind:
-# toast_success: Vinculación exitosa
-# toast_failed: Vinculación fallida, no se encontró ningún método de inicio de sesión habilitado.
-# sidebar:
-# search:
-# placeholder: Buscar
-# menu:
-# groups:
-# content: Contenido
-# interface: Interfaz
-# system: Sistema
-# tool: Herramienta
-# items:
-# dashboard: Panel de control
-# posts: Publicaciones
-# single_pages: Páginas
-# comments: Comentarios
-# attachments: Archivos adjuntos
-# themes: Temas
-# menus: Menús
-# plugins: Complementos
-# users: Usuarios
-# settings: Configuraciones
-# actuator: Actuador
-# backup: Respaldo
-# operations:
-# logout:
-# button: Cerrar sesión
-# title: ¿Estás seguro de que deseas cerrar sesión?
-# profile:
-# button: Perfil
-# visit_homepage:
-# title: Visitar página de inicio
-# dashboard:
-# title: Panel de control
-# actions:
-# setting: Configuración
-# done: Hecho
-# add_widget: Agregar Widget
-# widgets:
-# modal_title: Widgets
-# groups:
-# post: Publicación
-# page: Página
-# comment: Comentario
-# user: Usuario
-# other: Otros
-# presets:
-# post_stats:
-# title: Publicaciones
-# page_stats:
-# title: Páginas
-# recent_published:
-# title: Publicaciones Recientes
-# visits: "{visits} Visitas"
-# comments: "{comments} Comentarios"
-# quicklink:
-# title: Enlace Rápido
-# actions:
-# user_center:
-# title: Perfil de Usuario
-# view_site:
-# title: Ver Sitio
-# new_post:
-# title: Nueva Publicación
-# new_page:
-# title: Nueva Página
-# upload_attachment:
-# title: Subir Archivo Adjunto
-# theme_manage:
-# title: Administrar Temas
-# plugin_manage:
-# title: Administrar Complementos
-# new_user:
-# title: Nuevo Usuario
-# refresh_search_engine:
-# title: Actualizar Motor de Búsqueda
-# dialog_title: ¿Deseas actualizar el índice del motor de búsqueda?
-# dialog_content: Esta operación recreará los índices del motor de búsqueda para todas las publicaciones publicadas.
-# success_message: Índice del motor de búsqueda actualizado exitosamente.
-# evict_page_cache:
-# title: Actualizar Caché de Página
-# dialog_title: ¿Deseas actualizar el caché de las páginas?
-# dialog_content: Esta operación borrará la caché de todas las páginas.
-# success_message: Caché de página actualizada exitosamente.
-# user_stats:
-# title: Usuarios
-# comment_stats:
-# title: Comentarios
-# views_stats:
-# title: Visitas
-# post:
-# title: Publicaciones
-# actions:
-# categories: Categorías
-# tags: Etiquetas
-# recycle_bin: Papelera de reciclaje
-# empty:
-# title: No hay publicaciones actualmente.
-# message: Puedes intentar actualizar o crear una nueva publicación.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar esta publicación?
-# description: Esta operación moverá la publicación a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar las publicaciones seleccionadas?
-# description: Esta operación moverá las publicaciones a la papelera de reciclaje, y podrán ser restauradas desde la papelera de reciclaje posteriormente.
-# filters:
-# status:
-# items:
-# published: Publicado
-# draft: Borrador
-# visible:
-# label: Visible
-# result: "Visible: {visible}"
-# items:
-# public: Público
-# private: Privado
-# category:
-# label: Categoría
-# result: "Categoría: {category}"
-# tag:
-# label: Etiqueta
-# result: "Etiqueta: {tag}"
-# author:
-# label: Autor
-# result: "Autor: {author}"
-# sort:
-# items:
-# publish_time_desc: Publicado más reciente
-# publish_time_asc: Publicado más antiguo
-# create_time_desc: Creado más reciente
-# create_time_asc: Creado más antiguo
-# list:
-# fields:
-# categories: "Categorías:"
-# visits: "{visits} Visitas"
-# comments: "{comments} Comentarios"
-# pinned: Fijado
-# settings:
-# title: Configuraciones
-# groups:
-# general: General
-# advanced: Avanzado
-# annotations: Anotaciones
-# fields:
-# title:
-# label: Título
-# slug:
-# label: Slug
-# help: Usualmente usado para generar el enlace permanente a las publicaciones
-# refresh_message: Regenerar slug basado en el título.
-# categories:
-# label: Categorías
-# tags:
-# label: Etiquetas
-# auto_generate_excerpt:
-# label: Generar Extracto Automáticamente
-# raw_excerpt:
-# label: Extracto
-# allow_comment:
-# label: Permitir Comentarios
-# pinned:
-# label: Fijado
-# visible:
-# label: Visible
-# publish_time:
-# label: Hora de Publicación
-# template:
-# label: Plantilla
-# cover:
-# label: Portada
-# deleted_post:
-# title: Publicaciones eliminadas
-# empty:
-# title: No se han colocado publicaciones en la papelera de reciclaje.
-# message: Puedes intentar actualizar o volver a la página anterior.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar permanentemente esta publicación?
-# description: Después de la eliminación, no será posible recuperarla.
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar permanentemente las publicaciones seleccionadas?
-# description: Después de la eliminación, no será posible recuperarlas.
-# recovery:
-# title: ¿Quieres restaurar esta publicación?
-# description: Esta operación restaurará la publicación a su estado antes de la eliminación.
-# recovery_in_batch:
-# title: ¿Estás seguro de que deseas restaurar las publicaciones seleccionadas?
-# description: Esta operación restaurará las publicaciones a su estado antes de la eliminación.
-# post_editor:
-# title: Edición de publicación
-# untitled: Publicación sin título
-# post_tag:
-# title: Etiquetas de publicación
-# header:
-# title: "{count} Etiquetas"
-# empty:
-# title: No hay etiquetas actualmente.
-# message: Puedes intentar actualizar o crear una nueva etiqueta.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar esta etiqueta?
-# description: Después de eliminar esta etiqueta, se eliminará la asociación con el artículo correspondiente. Esta operación no se puede deshacer.
-# editing_modal:
-# titles:
-# update: Actualizar etiqueta de publicación
-# create: Crear etiqueta de publicación
-# groups:
-# general: General
-# annotations: Anotaciones
-# fields:
-# display_name:
-# label: Nombre para mostrar
-# slug:
-# label: Slug
-# help: Usualmente utilizado para generar el enlace permanente de las etiquetas
-# refresh_message: Regenerar slug basado en el nombre para mostrar.
-# color:
-# label: Color
-# help: Se requiere adaptación del tema para ser compatible
-# cover:
-# label: Portada
-# help: Se requiere adaptación del tema para ser compatible
-# post_category:
-# title: Categorías de publicación
-# header:
-# title: "{count} Categorías"
-# empty:
-# title: No hay categorías actualmente.
-# message: Puedes intentar actualizar o crear una nueva categoría.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar esta categoría?
-# description: Después de eliminar esta categoría, se eliminará la asociación con los artículos correspondientes. Esta operación no se puede deshacer.
-# add_sub_category:
-# button: Agregar subcategoría
-# editing_modal:
-# titles:
-# update: Actualizar categoría de publicación
-# create: Crear categoría de publicación
-# groups:
-# general: General
-# annotations: Anotaciones
-# fields:
-# parent:
-# label: Padre
-# display_name:
-# label: Nombre para mostrar
-# slug:
-# label: Slug
-# help: Usualmente utilizado para generar el enlace permanente de las categorías
-# refresh_message: Regenerar slug basado en el nombre para mostrar.
-# template:
-# label: Plantilla personalizada
-# cover:
-# label: Portada
-# help: Se requiere adaptación del tema para ser compatible
-# description:
-# label: Descripción
-# help: Se requiere adaptación del tema para ser compatible
-# page:
-# title: Páginas
-# actions:
-# recycle_bin: Papelera de reciclaje
-# empty:
-# title: No hay páginas actualmente.
-# message: Puedes intentar actualizar o crear una nueva página.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar esta página?
-# description: Esta operación moverá la página a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar las páginas seleccionadas?
-# description: Esta operación moverá las páginas a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
-# filters:
-# status:
-# items:
-# published: Publicado
-# draft: Borrador
-# visible:
-# label: Visible
-# result: "Visible: {visible}"
-# items:
-# public: Público
-# private: Privado
-# author:
-# label: Autor
-# result: "Autor: {author}"
-# sort:
-# items:
-# publish_time_desc: Publicado más reciente
-# publish_time_asc: Publicado más antiguo
-# create_time_desc: Creado más reciente
-# create_time_asc: Creado más antiguo
-# list:
-# fields:
-# visits: "{visits} Visitas"
-# comments: "{comments} Comentarios"
-# settings:
-# title: Configuraciones
-# groups:
-# general: General
-# advanced: Avanzado
-# annotations: Anotaciones
-# fields:
-# title:
-# label: Título
-# slug:
-# label: Slug
-# help: Usualmente utilizado para generar el enlace permanente de las páginas
-# refresh_message: Regenerar slug basado en el título.
-# auto_generate_excerpt:
-# label: Generar Extracto Automáticamente
-# raw_excerpt:
-# label: Extracto
-# allow_comment:
-# label: Permitir Comentarios
-# pinned:
-# label: Fijado
-# visible:
-# label: Visible
-# publish_time:
-# label: Hora de Publicación
-# template:
-# label: Plantilla
-# cover:
-# label: Portada
-# deleted_page:
-# title: Páginas Eliminadas
-# empty:
-# title: No hay páginas en la papelera de reciclaje.
-# message: Puedes intentar actualizar o volver a la página anterior.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar permanentemente esta página?
-# description: Después de la eliminación, no será posible recuperarla.
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar permanentemente las páginas seleccionadas?
-# description: Después de la eliminación, no será posible recuperarlas.
-# recovery:
-# title: ¿Quieres restaurar esta página?
-# description: Esta operación restaurará la página a su estado antes de la eliminación.
-# recovery_in_batch:
-# title: ¿Estás seguro de que deseas restaurar las páginas seleccionadas?
-# description: Esta operación restaurará las páginas a su estado antes de la eliminación.
-# page_editor:
-# title: Edición de Página
-# untitled: Página Sin Título
-# comment:
-# title: Comentarios
-# empty:
-# title: No hay comentarios actualmente.
-# message: Puedes intentar actualizar o modificar los criterios de filtrado.
-# reply_empty:
-# title: No hay respuestas actualmente.
-# message: Puedes intentar actualizar o crear una nueva respuesta.
-# new: Nueva Respuesta
-# operations:
-# delete_comment:
-# title: ¿Estás seguro de que deseas eliminar este comentario?
-# description: Todas las respuestas bajo los comentarios se eliminarán al mismo tiempo, y esta operación no se puede deshacer.
-# delete_comment_in_batch:
-# title: ¿Estás seguro de que deseas eliminar los comentarios seleccionados?
-# description: Todas las respuestas bajo los comentarios se eliminarán al mismo tiempo, y esta operación no se puede deshacer.
-# approve_comment_in_batch:
-# button: Aprobar
-# title: ¿Estás seguro de que deseas aprobar los comentarios seleccionados para su revisión?
-# approve_applies_in_batch:
-# button: Aprobar todas las respuestas
-# title: ¿Estás seguro de que deseas aprobar todas las respuestas a este comentario para su revisión?
-# delete_reply:
-# title: ¿Estás seguro de que deseas eliminar esta respuesta?
-# approve_reply:
-# button: Aprobar
-# reply:
-# button: Responder
-# filters:
-# status:
-# items:
-# approved: Aprobado
-# pending_review: Pendiente de Revisión
-# owner:
-# label: Propietario
-# result: "Propietario: {owner}"
-# sort:
-# items:
-# last_reply_time_desc: Respuesta Reciente
-# last_reply_time_asc: Respuesta Antigua
-# reply_count_desc: Más Respuestas
-# reply_count_asc: Menos Respuestas
-# create_time_desc: Creado más reciente
-# create_time_asc: Creado más antiguo
-# list:
-# fields:
-# reply_count: "{count} Respuestas"
-# has_new_replies: Nuevas respuestas
-# pending_review: Pendiente de revisión
-# subject_refs:
-# post: Publicación
-# page: Página
-# unknown: Desconocido
-# reply_modal:
-# title: Respuesta
-# fields:
-# content:
-# label: Contenido
-# operations:
-# submit:
-# toast_success: Respuesta enviada exitosamente
-# attachment:
-# title: Adjuntos
-# common:
-# text:
-# ungrouped: Sin grupo
-# actions:
-# storage_policies: Políticas de Almacenamiento
-# empty:
-# title: No hay adjuntos en el grupo actual.
-# message: El grupo actual no tiene adjuntos, puedes intentar actualizar o cargar adjuntos.
-# actions:
-# upload: Cargar Adjunto
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar este adjunto?
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar los adjuntos seleccionados?
-# deselect_items:
-# button: Deseleccionar ítems
-# move:
-# button: Mover
-# toast_success: Movimiento exitoso
-# filters:
-# storage_policy:
-# label: Política de Almacenamiento
-# result: "Política de Almacenamiento: {storage_policy}"
-# owner:
-# label: Propietario
-# result: "Propietario: {owner}"
-# sort:
-# items:
-# create_time_desc: Cargado más reciente
-# create_time_asc: Cargado más antiguo
-# size_desc: Ordenar por tamaño descendente
-# size_asc: Ordenar por tamaño ascendente
-# view_type:
-# items:
-# grid: Modo Cuadrícula
-# list: Modo Lista
-# detail_modal:
-# title: "Adjunto: {display_name}"
-# fields:
-# preview: Vista Previa
-# storage_policy: Política de Almacenamiento
-# group: Grupo
-# display_name: Nombre para Mostrar
-# media_type: Tipo de Medio
-# size: Tamaño
-# owner: Propietario
-# creation_time: Hora de Creación
-# permalink: Enlace Permanente
-# preview:
-# click_to_exit: Haz clic para salir de la vista previa
-# video_not_support: El navegador actual no admite la reproducción de video.
-# audio_not_support: El navegador actual no admite la reproducción de audio.
-# not_support: Este archivo no admite la vista previa.
-# group_editing_modal:
-# titles:
-# create: Crear grupo de adjuntos
-# update: Actualizar grupo de adjuntos
-# fields:
-# display_name:
-# label: Nombre para Mostrar
-# group_list:
-# internal_groups:
-# all: Todo
-# operations:
-# rename:
-# button: Cambiar nombre
-# delete:
-# button: Y mover adjunto a sin grupo
-# title: ¿Estás seguro de que deseas eliminar este grupo?
-# description: El grupo se eliminará, y los adjuntos bajo el grupo se moverán a sin grupo. Esta operación no se puede deshacer.
-# toast_success: Eliminación exitosa, {total} adjuntos se han movido a sin grupo
-# delete_with_attachments:
-# button: También eliminar adjuntos
-# title: ¿Estás seguro de que deseas eliminar este grupo?
-# description: Al eliminar el grupo y todos los adjuntos dentro de él, esta acción no se puede deshacer.
-# toast_success: Eliminación exitosa, {total} adjuntos se han eliminado simultáneamente
-# policies_modal:
-# title: Políticas de Almacenamiento
-# empty:
-# title: Actualmente no hay estrategias de almacenamiento disponibles.
-# message: No hay políticas de almacenamiento disponibles en este momento. Puedes intentar actualizar o crear una nueva política.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar esta política?
-# description: No hay adjuntos cargados bajo la política actual.
-# can_not_delete:
-# title: Fallo en la eliminación
-# description: Hay adjuntos bajo esta política, que no se pueden eliminar.
-# policy_editing_modal:
-# titles:
-# create: "Nueva política: {policy_template}"
-# update: "Editar política: {policy}"
-# fields:
-# display_name:
-# label: Nombre para Mostrar
-# upload_modal:
-# title: Cargar adjunto
-# filters:
-# group:
-# label: "Seleccionar grupo:"
-# policy:
-# label: "Seleccionar política de almacenamiento:"
-# empty:
-# title: Sin política de almacenamiento
-# description: Antes de cargar, es necesario crear una nueva política de almacenamiento.
-# not_select: Por favor, selecciona una política de almacenamiento primero
-# select_modal:
-# title: Seleccionar adjunto
-# providers:
-# default:
-# label: Adjuntos
-# operations:
-# select:
-# result: "({count} elementos seleccionados)"
-# theme:
-# title: Temas
-# common:
-# buttons:
-# install: Instalar Tema
-# tabs:
-# detail: Detalles
-# actions:
-# management: Gestión de Temas
-# empty:
-# title: No hay temas activados o seleccionados actualmente.
-# message: Puedes cambiar de tema o instalar nuevos.
-# actions:
-# switch: Cambiar de Tema
-# operations:
-# active:
-# title: ¿Estás seguro de activar el tema actual?
-# toast_success: Tema activado exitosamente
-# reset:
-# title: ¿Estás seguro de que deseas restablecer todas las configuraciones del tema?
-# description: Esta operación eliminará la configuración guardada y la restablecerá a los ajustes predeterminados.
-# toast_success: Configuración restablecida exitosamente
-# reload:
-# button: Recargar
-# title: ¿Estás seguro de que deseas recargar todas las configuraciones del tema?
-# description: Esta operación solo recargará la configuración del tema y la definición del formulario de ajustes, y no eliminará ninguna configuración guardada.
-# toast_success: Recarga de configuración exitosa
-# uninstall:
-# title: ¿Estás seguro de que deseas desinstalar este tema?
-# uninstall_and_delete_config:
-# button: Desinstalar y eliminar configuración
-# title: ¿Estás seguro de que deseas desinstalar este tema y su configuración correspondiente?
-# remote_download:
-# title: Se ha detectado una dirección de descarga remota, ¿deseas descargar?
-# description: "Por favor, verifica cuidadosamente si esta dirección es confiable: {url}"
-# upload_modal:
-# titles:
-# install: Instalar tema
-# upgrade: Actualizar tema ({display_name})
-# operations:
-# existed_during_installation:
-# title: El tema ya existe.
-# description: El tema instalado actualmente ya existe, deseas actualizarlo?
-# tabs:
-# local: Local
-# remote:
-# title: Remoto
-# fields:
-# url: URL Remota
-# list_modal:
-# titles:
-# installed_themes: Temas Instalados
-# not_installed_themes: Temas no Instalados
-# tabs:
-# installed: Instalados
-# not_installed: No Instalados
-# empty:
-# title: No hay temas instalados actualmente.
-# message: No hay temas instalados actualmente, puedes intentar actualizar o instalar un nuevo tema.
-# not_installed_empty:
-# title: No hay temas actualmente no instalados.
-# preview_model:
-# title: "Vista Previa del Tema: {display_name}"
-# actions:
-# switch: Cambiar de tema
-# setting: Ajustes
-# open: Abrir
-# detail:
-# fields:
-# author: Autor
-# website: Sitio Web
-# repo: Repositorio Fuente
-# version: Versión
-# requires: Requiere
-# storage_location: Ubicación de Almacenamiento
-# plugin_requires: Requiere Plugin
-# settings:
-# title: Ajustes del Tema
-# custom_templates:
-# default: Predeterminado
-# menu:
-# title: Menús
-# empty:
-# title: Actualmente no hay menús.
-# message: Puedes intentar actualizar o crear un nuevo menú.
-# menu_item_empty:
-# title: Actualmente no hay elementos de menú.
-# message: Puedes intentar actualizar o crear un nuevo elemento de menú.
-# operations:
-# set_primary:
-# button: Establecer como menú principal
-# toast_success: Configuración exitosa
-# delete_menu:
-# title: "¿Estás seguro de que deseas eliminar este menú?"
-# description: Todos los elementos de menú de este menú se eliminarán al mismo tiempo y esta operación no se puede deshacer.
-# delete_menu_item:
-# title: "¿Estás seguro de que deseas eliminar este elemento de menú?"
-# description: Todos los subelementos de menú se eliminarán al mismo tiempo y no se pueden restaurar después de la eliminación.
-# add_sub_menu_item:
-# button: Agregar subelemento de menú
-# list:
-# fields:
-# primary: Principal
-# items_count: "{count} elementos"
-# menu_editing_modal:
-# titles:
-# create: Crear menú
-# update: Actualizar menú
-# fields:
-# display_name:
-# label: Nombre para mostrar
-# menu_item_editing_modal:
-# titles:
-# create: Crear elemento de menú
-# update: Actualizar elemento de menú
-# groups:
-# general: General
-# annotations: Anotaciones
-# fields:
-# parent:
-# label: Padre
-# placeholder: Selecciona el elemento de menú padre
-# ref_kind:
-# label: Tipo
-# placeholder: "Por favor selecciona {label}"
-# options:
-# custom: Personalizado
-# post: Publicación
-# single_page: Página
-# category: Categoría
-# tag: Etiqueta
-# display_name:
-# label: Nombre para mostrar
-# href:
-# label: Dirección del enlace
-# target:
-# label: Destino
-# options:
-# self: _self
-# blank: _blank
-# parent: _parent
-# top: _top
-# plugin:
-# title: Plugins
-# tabs:
-# detail: Detail
-# empty:
-# title: There are no installed plugins currently.
-# message: There are no installed plugins currently, you can try refreshing or installing new plugins.
-# actions:
-# install: Install Plugin
-# operations:
-# reset:
-# title: Are you sure you want to reset all configurations of the plugin?
-# description: This operation will delete the saved configuration and reset it to default settings.
-# toast_success: Reset configuration successfully
-# uninstall:
-# title: Are you sure you want to uninstall this plugin?
-# uninstall_and_delete_config:
-# title: Are you sure you want to uninstall this plugin and its corresponding configuration?
-# uninstall_when_enabled:
-# confirm_text: Stop running and uninstall
-# description: The current plugin is still in the enabled state and will be uninstalled after it stops running. This operation cannot be undone.
-# change_status:
-# active_title: Are you sure you want to active this plugin?
-# inactive_title: Are you sure you want to inactive this plugin?
-# remote_download:
-# title: Remote download address detected, do you want to download?
-# description: "Please carefully verify whether this address can be trusted: {url}"
-# filters:
-# status:
-# items:
-# active: Active
-# inactive: Inactive
-# sort:
-# items:
-# create_time_desc: Latest Installed
-# create_time_asc: Earliest Installed
-# list:
-# actions:
-# uninstall_and_delete_config: Uninstall and delete config
-# upload_modal:
-# titles:
-# install: Install plugin
-# upgrade: Upgrade plugin ({display_name})
-# tabs:
-# local: Local
-# remote:
-# title: Remote
-# fields:
-# url: Remote URL
-# operations:
-# active_after_install:
-# title: Install successful
-# description: Would you like to activate the currently installed plugin?
-# existed_during_installation:
-# title: The plugin already exists.
-# description: The currently installed plugin already exists, do you want to upgrade?
-# detail:
-# title: Plugin detail
-# header:
-# title: Plugin information
-# fields:
-# display_name: Display Name
-# description: Description
-# version: Version
-# requires: Requires
-# author: Author
-# license: License
-# role_templates: Role Templates
-# last_starttime: Last Start Time
-# loader:
-# toast:
-# entry_load_failed: "{name}: Failed to load plugin entry file"
-# style_load_failed: "{name}: Failed to load plugin stylesheet file"
-# extension_points:
-# editor:
-# providers:
-# default: Default Editor
-# user:
-# title: Usuarios
-# actions:
-# roles: Roles
-# identity_authentication: Autenticación de Identidad
-# empty:
-# title: Actualmente no hay usuarios que cumplan con los criterios de filtrado.
-# message: No hay usuarios que coincidan con los criterios de filtrado en este momento. Puedes intentar actualizar o crear un nuevo usuario.
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar a este usuario?
-# delete_in_batch:
-# title: ¿Estás seguro de que deseas eliminar a los usuarios seleccionados?
-# update_profile:
-# title: Actualizar perfil
-# change_password:
-# title: Cambiar contraseña
-# grant_permission:
-# title: Conceder permiso
-# filters:
-# role:
-# label: Rol
-# result: "Rol: {role}"
-# sort:
-# items:
-# create_time_desc: Últimos creados
-# create_time_asc: Más antiguos creados
-# editing_modal:
-# titles:
-# update: Editar usuario
-# create: Crear usuario
-# groups:
-# general: General
-# annotations: Anotaciones
-# fields:
-# username:
-# label: Nombre de usuario
-# validation: Por favor, introduce un nombre de usuario válido.
-# display_name:
-# label: Nombre para mostrar
-# email:
-# label: Correo electrónico
-# phone:
-# label: Teléfono
-# avatar:
-# label: Avatar
-# bio:
-# label: Biografía
-# change_password_modal:
-# title: Cambiar contraseña
-# fields:
-# new_password:
-# label: Nueva contraseña
-# confirm_password:
-# label: Confirmar contraseña
-# grant_permission_modal:
-# title: Conceder permiso
-# fields:
-# role:
-# label: Rol
-# placeholder: Por favor, selecciona un rol
-# detail:
-# title: Detalles del usuario
-# tabs:
-# detail: Detalle
-# actions:
-# update_profile:
-# title: Actualizar perfil
-# change_password:
-# title: Cambiar contraseña
-# operations:
-# bind:
-# button: Vincular
-# unbind:
-# button: Desvincular
-# title: ¿Estás seguro de que deseas desvincular el método de inicio de sesión para {display_name}?
-# fields:
-# display_name: Nombre para mostrar
-# username: Nombre de usuario
-# email: Correo electrónico
-# roles: Roles
-# bio: Biografía
-# creation_time: Fecha de creación
-# identity_authentication: Autenticación de identidad
-# avatar:
-# title: Avatar
-# toast_upload_failed: No se pudo cargar el avatar
-# toast_remove_failed: No se pudo eliminar el avatar
-# cropper_modal:
-# title: Recortar Avatar
-# remove:
-# title: ¿Estás seguro de que deseas eliminar el avatar?
-# tooltips:
-# upload: Cargar
-# zoom_in: Acercar
-# zoom_out: Alejar
-# flip_horizontal: Voltear Horizontalmente
-# flip_vertical: Voltear Verticalmente
-# reset: Restablecer
-# role:
-# title: Roles
-# common:
-# text:
-# contains_all_permissions: Contiene todos los permisos
-# contains_n_permissions: Contiene {count} permisos
-# system_reserved: Reservado del Sistema
-# custom: Personalizado
-# dependent_on: Dependiente de {roles}
-# provided_by_plugin: Proporcionado por {plugin}
-# operations:
-# delete:
-# title: ¿Estás seguro de que deseas eliminar este rol?
-# description: Una vez eliminado el rol, se eliminarán las asignaciones de rol de los usuarios asociados y esta operación no se puede deshacer.
-# create_based_on_this_role:
-# button: Crear basado en este rol
-# detail:
-# title: Detalle del rol
-# header:
-# title: Información del rol
-# tabs:
-# detail: Detalle
-# permissions: Permisos
-# fields:
-# display_name: Nombre para mostrar
-# name: Nombre
-# type: Tipo
-# creation_time: Fecha de creación
-# permissions_detail:
-# system_reserved_alert:
-# description: El rol reservado del sistema no admite modificaciones. Se recomienda crear un nuevo rol basado en este.
-# editing_modal:
-# titles:
-# create: Crear rol
-# update: Actualizar rol
-# groups:
-# general: General
-# permissions: Permisos
-# fields:
-# display_name: Nombre para mostrar
-# identity_authentication:
-# title: Autenticación de Identidad
-# tabs:
-# detail: Detalle
-# setting: Configuración
-# operations:
-# enable:
-# title: ¿Estás seguro de que deseas habilitar este método de autenticación de identidad?
-# disable:
-# title: ¿Estás seguro de que deseas deshabilitar este método de autenticación de identidad?
-# disable_privileged:
-# tooltip: El método de autenticación reservado por el sistema no se puede deshabilitar
-# detail:
-# title: Detalle de la autenticación de identidad
-# fields:
-# display_name: Nombre para mostrar
-# description: Descripción
-# website: Sitio web
-# help_page: Página de ayuda
-# authentication_url: URL de inicio de sesión
-# setting:
-# title: Configuraciones
-# actuator:
-# title: Actuador
-# actions:
-# copy:
-# toast_browser_not_supported: El navegador actual no admite la función de copiado
-# header:
-# titles:
-# general: Información general
-# environment: Información del entorno
-# fields:
-# external_url: URL externa
-# start_time: Hora de inicio
-# timezone: Zona horaria
-# locale: Idioma
-# version: Versión
-# build_time: Fecha de compilación
-# database: Base de datos
-# os: Sistema operativo
-# log: Registro del sistema
-# fields_values:
-# external_url:
-# not_setup: No configurado
-# copy_results:
-# external_url: "URL externa: {external_url}"
-# start_time: "Hora de inicio: {start_time}"
-# version: "Versión: {version}"
-# build_time: "Fecha de compilación: {build_time}"
-# database: "Base de datos: {database}"
-# os: "Sistema operativo: {os}"
-# alert:
-# external_url_invalid: La URL de acceso externo detectada no coincide con la URL de acceso actual, lo que podría causar que algunos enlaces no se redirijan correctamente. Por favor, verifica la configuración de la URL de acceso externo.
-# backup:
-# title: Copia de Seguridad y Restauración
-# tabs:
-# backup_list: Copias de Seguridad
-# restore: Restaurar
-# empty:
-# title: Aún no se han creado copias de seguridad
-# message: Puedes hacer clic en el botón debajo para crear una copia de seguridad
-# operations:
-# create:
-# button: Crear copia de seguridad
-# title: Crear copia de seguridad
-# description: ¿Estás seguro de que deseas crear una copia de seguridad? Esta operación puede tomar un tiempo.
-# toast_success: Solicitud de creación de copia de seguridad realizada
-# delete:
-# title: Eliminar copia de seguridad
-# description: ¿Estás seguro de que deseas eliminar esta copia de seguridad?
-# restore:
-# title: Restauración exitosa
-# description: Después de una restauración exitosa, necesitas reiniciar Halo para cargar los recursos del sistema normalmente. Después de hacer clic en OK, reiniciaremos automáticamente Halo.
-# restart:
-# toast_success: Solicitud de reinicio realizada
-# list:
-# phases:
-# pending: Pendiente
-# running: En Progreso
-# succeeded: Exitosa
-# failed: Fallida
-# fields:
-# expiresAt: Expira el {expiresAt}
-# restore:
-# tips:
-# first: 1. El proceso de restauración puede tomar un tiempo, por favor no actualices la página durante este período.
-# second: 2. Durante el proceso de restauración, aunque los datos existentes no se eliminarán, si hay un conflicto, los datos se sobrescribirán.
-# third: 3. Después de completar la restauración, necesitas reiniciar Halo para cargar los recursos del sistema normalmente.
-# complete: Restauración completada, esperando reinicio...
-# start: Iniciar Restauración
-# exception:
-# not_found:
-# message: Página no encontrada
-# forbidden:
-# message: Acceso no autorizado a esta página
-# actions:
-# home: Ir a la página de inicio
-# setup:
-# title: Configuración
-# operations:
-# submit:
-# button: Configurar
-# toast_success: Configuración exitosa
-# setup_initial_data:
-# loading: Inicializando datos, por favor espera...
-# fields:
-# site_title:
-# label: Título del Sitio
-# email:
-# label: Correo Electrónico
-# username:
-# label: Nombre de Usuario
-# password:
-# label: Contraseña
-# confirm_password:
-# label: Confirmar Contraseña
-# rbac:
-# Attachments Management: Gestión de archivos adjuntos
-# Attachment Manage: Gestor de adjuntos
-# Attachment View: Vista de adjuntos
-# role-template-view-attachments: Vista de adjuntos
-# Comments Management: Comentarios
-# Comment Manage: Gestor de comentarios
-# Comment View: Vista de comentarios
-# role-template-view-comments: Vista de comentarios
-# ConfigMaps Management: ConfigMaps
-# ConfigMap Manage: Gestor de ConfigMaps
-# ConfigMap View: Vista de ConfigMaps
-# role-template-view-configmaps: Vista de ConfigMaps
-# Menus Management: Menús
-# Menu Manage: Gestor de menús
-# Menu View: Vista de menús
-# role-template-view-menus: Vista de menús
-# Permissions Management: Permisos
-# Permissions Manage: Gestor de permisos
-# Permissions View: Vista de permisos
-# role-template-view-permissions: Vista de permisos
-# role-template-manage-permissions: Gestor de permisos
-# Plugins Management: Plugins
-# Plugin Manage: Gestor de plugins
-# Plugin View: Vista de plugins
-# role-template-view-plugins: Vista de plugins
-# Posts Management: Publicaciones
-# Post Manage: Gestor de publicaciones
-# Post View: Vista de publicaciones
-# role-template-view-posts: Vista de publicaciones
-# role-template-manage-snapshots: Gestor de snapshots
-# role-template-view-snapshots: Vista de snapshots
-# role-template-manage-tags: Gestor de etiquetas
-# role-template-view-tags: Vista de etiquetas
-# role-template-manage-categories: Gestor de categorías
-# role-template-view-categories: Vista de categorías
-# Roles Management: Roles
-# Role Manage: Gestor de roles
-# Role View: Vista de roles
-# role-template-view-roles: Vista de roles
-# Settings Management: Configuración
-# Setting Manage: Gestor de configuración
-# Setting View: Vista de configuración
-# role-template-view-settings: Vista de configuración
-# SinglePages Management: SinglePages
-# SinglePage Manage: Gestor de SinglePages
-# SinglePage View: Vista de SinglePages
-# role-template-view-singlepages: Vista de SinglePages
-# Themes Management: Temas
-# Theme Manage: Gestor de temas
-# Theme View: Vista de temas
-# role-template-view-themes: Vista de temas
-# Users Management: Usuarios
-# User manage: Gestor de usuarios
-# User View: Vista de usuarios
-# Migration Management: Copia de seguridad y restauración
-# Migration Manage: Gestor de copia de seguridad y restauración
-# role-template-view-users: Vista de usuarios
-# role-template-change-password: Cambiar contraseña
-# components:
-# submit_button:
-# computed_text: "{text} ({shortcut})"
-# annotations_form:
-# custom_fields:
-# label: Personalizado
-# validation: La clave actual ya está en uso
-# default_editor:
-# tabs:
-# toc:
-# title: Índice
-# empty: No hay índice disponible
-# detail:
-# title: Detalle
-# fields:
-# character_count: Conteo de caracteres
-# word_count: Conteo de palabras
-# publish_time: Hora de publicación
-# draft: Borrador
-# owner: Propietario
-# permalink: Enlace permanente
-# extensions:
-# placeholder:
-# options:
-# placeholder: "Ingresa / para seleccionar el tipo de entrada."
-# toolbox:
-# attachment: Adjunto
-# global_search:
-# placeholder: Ingresa palabras clave para buscar
-# no_results: Sin resultados de búsqueda
-# buttons:
-# select: Seleccionar
-# groups:
-# console: Página de la consola
-# user: Usuario
-# plugin: Plugin
-# post: Publicación
-# category: Categoría
-# tag: Etiqueta
-# page: Página
-# attachment: Adjunto
-# setting: Configuración
-# theme_setting: Configuración del tema
-# pagination:
-# page_label: página
-# size_label: elementos por página
-# total_label: Total de {total} elementos
-# social_auth_providers:
-# title: Inicio de sesión de terceros
-# app_download_alert:
-# description: "Los temas y complementos para Halo se pueden descargar en las siguientes direcciones:"
-# sources:
-# app_store: "Tienda de aplicaciones oficial: {url}"
-# github: "GitHub: {url}"
-# composables:
-# content_cache:
-# toast_recovered: Contenido no guardado recuperado de la caché
-# formkit:
-# category_select:
-# creation_label: "Crear categoría {text}"
-# tag_select:
-# creation_label: "Crear etiqueta {text}"
-# validation:
-# trim: Por favor, elimina los espacios al inicio y al final
-# common:
-# buttons:
-# save: Guardar
-# close: Cerrar
-# close_and_shortcut: Cerrar (Esc)
-# delete: Borrar
-# setting: Configuración
-# confirm: Confirmar
-# cancel: Cancelar
-# cancel_and_shortcut: Cancelar (Esc)
-# new: Nuevo
-# edit: Editar
-# back: Volver
-# refresh: Actualizar
-# publish: Publicar
-# cancel_publish: Cancelar Publicación
-# next: Siguiente
-# previous: Anterior
-# install: Instalar
-# uninstall: Desinstalar
-# upgrade: Actualizar
-# reset: Reiniciar
-# preview: Vista previa
-# recovery: Recuperar
-# delete_permanently: Borrar permanentemente
-# active: Activar
-# download: Descargar
-# copy: Copiar
-# upload: Subir
-# add: Agregar
-# submit: Enviar
-# detail: Detalle
-# radio:
-# "yes": Sí
-# "no": No
-# select:
-# public: Público
-# private: Privado
-# placeholder:
-# search: Ingresa palabras clave para buscar
-# toast:
-# operation_success: Operación realizada con éxito
-# delete_success: Eliminado exitosamente
-# save_success: Guardado exitosamente
-# publish_success: Publicado exitosamente
-# cancel_publish_success: Publicación cancelada exitosamente
-# recovery_success: Recuperado exitosamente
-# uninstall_success: Desinstalado exitosamente
-# active_success: Activado exitosamente
-# inactive_success: Desactivado exitosamente
-# upgrade_success: Actualizado exitosamente
-# install_success: Instalado exitosamente
-# download_success: Descargado exitosamente
-# copy_success: Copiado exitosamente
-# operation_failed: Fallo en la operación
-# download_failed: Fallo en la descarga
-# save_failed_and_retry: "Fallo al guardar, por favor intenta nuevamente"
-# publish_failed_and_retry: "Fallo al publicar, por favor intenta nuevamente"
-# network_error: "Error de red, por favor verifica tu conexión"
-# login_expired: "Sesión expirada, por favor inicia sesión nuevamente"
-# forbidden: Acceso denegado
-# not_found: Recurso no encontrado
-# server_internal_error: Error interno del servidor
-# unknown_error: Error desconocido
-# dialog:
-# titles:
-# tip: Consejo
-# warning: Advertencia
-# descriptions:
-# cannot_be_recovered: Esta operación es irreversible.
-# editor_not_found: No se encontró ningún editor que coincida con el formato {raw_type}. Por favor verifica si el complemento del editor ha sido instalado.
-# filters:
-# results:
-# keyword: "Palabra clave: {keyword}"
-# sort: "Ordenar: {sort}"
-# status: "Estado: {status}"
-# labels:
-# sort: Ordenar
-# status: Estado
-# item_labels:
-# all: Todo
-# default: Por defecto
-# status:
-# deleting: Borrando
-# loading: Cargando
-# loading_error: Error al cargar
-# activated: Activado
-# not_activated: No activado
-# installed: Instalado
-# not_installed: No instalado
-# text:
-# none: Ninguno
-# tip: Consejo
-# warning: Advertencia
-# tooltips:
-# unpublished_content_tip: Hay contenido que ha sido guardado pero aún no ha sido publicado.
-# publishing: Publicando
-# recovering: Recuperando
-# fields:
-# post_count: "{count} Publicaciones"
-
core:
+ login:
+ title: Inicio de sesión
+ fields:
+ username:
+ placeholder: Usuario
+ password:
+ placeholder: Contraseña
+ operations:
+ submit:
+ toast_success: Inicio de sesión exitoso
+ toast_failed: Error en el inicio de sesión, nombre de usuario o contraseña incorrectos
+ toast_csrf: Token CSRF no válido, por favor inténtalo de nuevo
+ signup:
+ label: No tienes una cuenta
+ button: Registrarse ahora
+ return_login:
+ label: Ya tienes una cuenta
+ button: Iniciar sesión ahora
+ return_site: Volver a la página de inicio
+ button: Iniciar sesión
+ modal:
+ title: Volver a iniciar sesión
+ signup:
+ title: Registrarse
+ fields:
+ username:
+ placeholder: Nombre de usuario
+ display_name:
+ placeholder: Nombre para mostrar
+ password:
+ placeholder: Contraseña
+ password_confirm:
+ placeholder: Confirmar contraseña
+ operations:
+ submit:
+ button: Registrarse
+ toast_success: Registrado exitosamente
+ binding:
+ title: Vinculación de cuentas
+ common:
+ toast:
+ mounted: El método de inicio de sesión actual no está vinculado a una cuenta. Por favor, vincula o registra una nueva cuenta primero.
+ operations:
+ login_and_bind:
+ button: Iniciar sesión y vincular
+ signup_and_bind:
+ button: Registrarse y vincular
+ bind:
+ toast_success: Vinculación exitosa
+ toast_failed: Vinculación fallida, no se encontró ningún método de inicio de sesión habilitado.
+ sidebar:
+ search:
+ placeholder: Buscar
+ menu:
+ groups:
+ content: Contenido
+ interface: Interfaz
+ system: Sistema
+ tool: Herramienta
+ items:
+ dashboard: Panel de control
+ posts: Publicaciones
+ single_pages: Páginas
+ comments: Comentarios
+ attachments: Archivos adjuntos
+ themes: Temas
+ menus: Menús
+ plugins: Complementos
+ users: Usuarios
+ settings: Configuraciones
+ actuator: Actuador
+ backup: Respaldo
+ operations:
+ logout:
+ button: Cerrar sesión
+ title: ¿Estás seguro de que deseas cerrar sesión?
+ profile:
+ button: Perfil
+ visit_homepage:
+ title: Visitar página de inicio
+ dashboard:
+ title: Panel de control
+ actions:
+ setting: Configuración
+ done: Hecho
+ add_widget: Agregar Widget
+ widgets:
+ modal_title: Widgets
+ groups:
+ post: Publicación
+ page: Página
+ comment: Comentario
+ user: Usuario
+ other: Otros
+ presets:
+ post_stats:
+ title: Publicaciones
+ page_stats:
+ title: Páginas
+ recent_published:
+ title: Publicaciones Recientes
+ visits: "{visits} Visitas"
+ comments: "{comments} Comentarios"
+ quicklink:
+ title: Enlace Rápido
+ actions:
+ user_center:
+ title: Perfil de Usuario
+ view_site:
+ title: Ver Sitio
+ new_post:
+ title: Nueva Publicación
+ new_page:
+ title: Nueva Página
+ upload_attachment:
+ title: Subir Archivo Adjunto
+ theme_manage:
+ title: Administrar Temas
+ plugin_manage:
+ title: Administrar Complementos
+ new_user:
+ title: Nuevo Usuario
+ refresh_search_engine:
+ title: Actualizar Motor de Búsqueda
+ dialog_title: ¿Deseas actualizar el índice del motor de búsqueda?
+ dialog_content: Esta operación recreará los índices del motor de búsqueda para todas las publicaciones publicadas.
+ success_message: Índice del motor de búsqueda actualizado exitosamente.
+ evict_page_cache:
+ title: Actualizar Caché de Página
+ dialog_title: ¿Deseas actualizar el caché de las páginas?
+ dialog_content: Esta operación borrará la caché de todas las páginas.
+ success_message: Caché de página actualizada exitosamente.
+ user_stats:
+ title: Usuarios
+ comment_stats:
+ title: Comentarios
+ views_stats:
+ title: Visitas
+ post:
+ title: Publicaciones
+ actions:
+ categories: Categorías
+ tags: Etiquetas
+ recycle_bin: Papelera de reciclaje
+ empty:
+ title: No hay publicaciones actualmente.
+ message: Puedes intentar actualizar o crear una nueva publicación.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar esta publicación?
+ description: Esta operación moverá la publicación a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar las publicaciones seleccionadas?
+ description: Esta operación moverá las publicaciones a la papelera de reciclaje, y podrán ser restauradas desde la papelera de reciclaje posteriormente.
+ filters:
+ status:
+ items:
+ published: Publicado
+ draft: Borrador
+ visible:
+ label: Visible
+ result: "Visible: {visible}"
+ items:
+ public: Público
+ private: Privado
+ category:
+ label: Categoría
+ result: "Categoría: {category}"
+ tag:
+ label: Etiqueta
+ result: "Etiqueta: {tag}"
+ author:
+ label: Autor
+ result: "Autor: {author}"
+ sort:
+ items:
+ publish_time_desc: Publicado más reciente
+ publish_time_asc: Publicado más antiguo
+ create_time_desc: Creado más reciente
+ create_time_asc: Creado más antiguo
+ list:
+ fields:
+ categories: "Categorías:"
+ visits: "{visits} Visitas"
+ comments: "{comments} Comentarios"
+ pinned: Fijado
+ settings:
+ title: Configuraciones
+ groups:
+ general: General
+ advanced: Avanzado
+ annotations: Anotaciones
+ fields:
+ title:
+ label: Título
+ slug:
+ label: Slug
+ help: Usualmente usado para generar el enlace permanente a las publicaciones
+ refresh_message: Regenerar slug basado en el título.
+ categories:
+ label: Categorías
+ tags:
+ label: Etiquetas
+ auto_generate_excerpt:
+ label: Generar Extracto Automáticamente
+ raw_excerpt:
+ label: Extracto
+ allow_comment:
+ label: Permitir Comentarios
+ pinned:
+ label: Fijado
+ visible:
+ label: Visible
+ publish_time:
+ label: Hora de Publicación
+ template:
+ label: Plantilla
+ cover:
+ label: Portada
+ deleted_post:
+ title: Publicaciones eliminadas
+ empty:
+ title: No se han colocado publicaciones en la papelera de reciclaje.
+ message: Puedes intentar actualizar o volver a la página anterior.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar permanentemente esta publicación?
+ description: Después de la eliminación, no será posible recuperarla.
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar permanentemente las publicaciones seleccionadas?
+ description: Después de la eliminación, no será posible recuperarlas.
+ recovery:
+ title: ¿Quieres restaurar esta publicación?
+ description: Esta operación restaurará la publicación a su estado antes de la eliminación.
+ recovery_in_batch:
+ title: ¿Estás seguro de que deseas restaurar las publicaciones seleccionadas?
+ description: Esta operación restaurará las publicaciones a su estado antes de la eliminación.
+ post_editor:
+ title: Edición de publicación
+ untitled: Publicación sin título
+ post_tag:
+ title: Etiquetas de publicación
+ header:
+ title: "{count} Etiquetas"
+ empty:
+ title: No hay etiquetas actualmente.
+ message: Puedes intentar actualizar o crear una nueva etiqueta.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar esta etiqueta?
+ description: Después de eliminar esta etiqueta, se eliminará la asociación con el artículo correspondiente. Esta operación no se puede deshacer.
+ editing_modal:
+ titles:
+ update: Actualizar etiqueta de publicación
+ create: Crear etiqueta de publicación
+ groups:
+ general: General
+ annotations: Anotaciones
+ fields:
+ display_name:
+ label: Nombre para mostrar
+ slug:
+ label: Slug
+ help: Usualmente utilizado para generar el enlace permanente de las etiquetas
+ refresh_message: Regenerar slug basado en el nombre para mostrar.
+ color:
+ label: Color
+ help: Se requiere adaptación del tema para ser compatible
+ cover:
+ label: Portada
+ help: Se requiere adaptación del tema para ser compatible
+ post_category:
+ title: Categorías de publicación
+ header:
+ title: "{count} Categorías"
+ empty:
+ title: No hay categorías actualmente.
+ message: Puedes intentar actualizar o crear una nueva categoría.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar esta categoría?
+ description: Después de eliminar esta categoría, se eliminará la asociación con los artículos correspondientes. Esta operación no se puede deshacer.
+ add_sub_category:
+ button: Agregar subcategoría
+ editing_modal:
+ titles:
+ update: Actualizar categoría de publicación
+ create: Crear categoría de publicación
+ groups:
+ general: General
+ annotations: Anotaciones
+ fields:
+ parent:
+ label: Padre
+ display_name:
+ label: Nombre para mostrar
+ slug:
+ label: Slug
+ help: Usualmente utilizado para generar el enlace permanente de las categorías
+ refresh_message: Regenerar slug basado en el nombre para mostrar.
+ template:
+ label: Plantilla personalizada
+ cover:
+ label: Portada
+ help: Se requiere adaptación del tema para ser compatible
+ description:
+ label: Descripción
+ help: Se requiere adaptación del tema para ser compatible
+ page:
+ title: Páginas
+ actions:
+ recycle_bin: Papelera de reciclaje
+ empty:
+ title: No hay páginas actualmente.
+ message: Puedes intentar actualizar o crear una nueva página.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar esta página?
+ description: Esta operación moverá la página a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar las páginas seleccionadas?
+ description: Esta operación moverá las páginas a la papelera de reciclaje, y podrá ser restaurada desde la papelera de reciclaje posteriormente.
+ filters:
+ status:
+ items:
+ published: Publicado
+ draft: Borrador
+ visible:
+ label: Visible
+ result: "Visible: {visible}"
+ items:
+ public: Público
+ private: Privado
+ author:
+ label: Autor
+ result: "Autor: {author}"
+ sort:
+ items:
+ publish_time_desc: Publicado más reciente
+ publish_time_asc: Publicado más antiguo
+ create_time_desc: Creado más reciente
+ create_time_asc: Creado más antiguo
+ list:
+ fields:
+ visits: "{visits} Visitas"
+ comments: "{comments} Comentarios"
+ settings:
+ title: Configuraciones
+ groups:
+ general: General
+ advanced: Avanzado
+ annotations: Anotaciones
+ fields:
+ title:
+ label: Título
+ slug:
+ label: Slug
+ help: Usualmente utilizado para generar el enlace permanente de las páginas
+ refresh_message: Regenerar slug basado en el título.
+ auto_generate_excerpt:
+ label: Generar Extracto Automáticamente
+ raw_excerpt:
+ label: Extracto
+ allow_comment:
+ label: Permitir Comentarios
+ pinned:
+ label: Fijado
+ visible:
+ label: Visible
+ publish_time:
+ label: Hora de Publicación
+ template:
+ label: Plantilla
+ cover:
+ label: Portada
+ deleted_page:
+ title: Páginas Eliminadas
+ empty:
+ title: No hay páginas en la papelera de reciclaje.
+ message: Puedes intentar actualizar o volver a la página anterior.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar permanentemente esta página?
+ description: Después de la eliminación, no será posible recuperarla.
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar permanentemente las páginas seleccionadas?
+ description: Después de la eliminación, no será posible recuperarlas.
+ recovery:
+ title: ¿Quieres restaurar esta página?
+ description: Esta operación restaurará la página a su estado antes de la eliminación.
+ recovery_in_batch:
+ title: ¿Estás seguro de que deseas restaurar las páginas seleccionadas?
+ description: Esta operación restaurará las páginas a su estado antes de la eliminación.
+ page_editor:
+ title: Edición de Página
+ untitled: Página Sin Título
+ comment:
+ title: Comentarios
+ empty:
+ title: No hay comentarios actualmente.
+ message: Puedes intentar actualizar o modificar los criterios de filtrado.
+ reply_empty:
+ title: No hay respuestas actualmente.
+ message: Puedes intentar actualizar o crear una nueva respuesta.
+ new: Nueva Respuesta
+ operations:
+ delete_comment:
+ title: ¿Estás seguro de que deseas eliminar este comentario?
+ description: Todas las respuestas bajo los comentarios se eliminarán al mismo tiempo, y esta operación no se puede deshacer.
+ delete_comment_in_batch:
+ title: ¿Estás seguro de que deseas eliminar los comentarios seleccionados?
+ description: Todas las respuestas bajo los comentarios se eliminarán al mismo tiempo, y esta operación no se puede deshacer.
+ approve_comment_in_batch:
+ button: Aprobar
+ title: ¿Estás seguro de que deseas aprobar los comentarios seleccionados para su revisión?
+ approve_applies_in_batch:
+ button: Aprobar todas las respuestas
+ title: ¿Estás seguro de que deseas aprobar todas las respuestas a este comentario para su revisión?
+ delete_reply:
+ title: ¿Estás seguro de que deseas eliminar esta respuesta?
+ approve_reply:
+ button: Aprobar
+ reply:
+ button: Responder
+ filters:
+ status:
+ items:
+ approved: Aprobado
+ pending_review: Pendiente de Revisión
+ owner:
+ label: Propietario
+ result: "Propietario: {owner}"
+ sort:
+ items:
+ last_reply_time_desc: Respuesta Reciente
+ last_reply_time_asc: Respuesta Antigua
+ reply_count_desc: Más Respuestas
+ reply_count_asc: Menos Respuestas
+ create_time_desc: Creado más reciente
+ create_time_asc: Creado más antiguo
+ list:
+ fields:
+ reply_count: "{count} Respuestas"
+ has_new_replies: Nuevas respuestas
+ pending_review: Pendiente de revisión
+ subject_refs:
+ post: Publicación
+ page: Página
+ unknown: Desconocido
+ reply_modal:
+ title: Respuesta
+ fields:
+ content:
+ label: Contenido
+ operations:
+ submit:
+ toast_success: Respuesta enviada exitosamente
+ attachment:
+ title: Adjuntos
+ common:
+ text:
+ ungrouped: Sin grupo
+ actions:
+ storage_policies: Políticas de Almacenamiento
+ empty:
+ title: No hay adjuntos en el grupo actual.
+ message: El grupo actual no tiene adjuntos, puedes intentar actualizar o cargar adjuntos.
+ actions:
+ upload: Cargar Adjunto
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar este adjunto?
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar los adjuntos seleccionados?
+ deselect_items:
+ button: Deseleccionar ítems
+ move:
+ button: Mover
+ toast_success: Movimiento exitoso
+ filters:
+ storage_policy:
+ label: Política de Almacenamiento
+ result: "Política de Almacenamiento: {storage_policy}"
+ owner:
+ label: Propietario
+ result: "Propietario: {owner}"
+ sort:
+ items:
+ create_time_desc: Cargado más reciente
+ create_time_asc: Cargado más antiguo
+ size_desc: Ordenar por tamaño descendente
+ size_asc: Ordenar por tamaño ascendente
+ view_type:
+ items:
+ grid: Modo Cuadrícula
+ list: Modo Lista
+ detail_modal:
+ title: "Adjunto: {display_name}"
+ fields:
+ preview: Vista Previa
+ storage_policy: Política de Almacenamiento
+ group: Grupo
+ display_name: Nombre para Mostrar
+ media_type: Tipo de Medio
+ size: Tamaño
+ owner: Propietario
+ creation_time: Hora de Creación
+ permalink: Enlace Permanente
+ preview:
+ click_to_exit: Haz clic para salir de la vista previa
+ video_not_support: El navegador actual no admite la reproducción de video.
+ audio_not_support: El navegador actual no admite la reproducción de audio.
+ not_support: Este archivo no admite la vista previa.
+ group_editing_modal:
+ titles:
+ create: Crear grupo de adjuntos
+ update: Actualizar grupo de adjuntos
+ fields:
+ display_name:
+ label: Nombre para Mostrar
+ group_list:
+ internal_groups:
+ all: Todo
+ operations:
+ rename:
+ button: Cambiar nombre
+ delete:
+ button: Y mover adjunto a sin grupo
+ title: ¿Estás seguro de que deseas eliminar este grupo?
+ description: El grupo se eliminará, y los adjuntos bajo el grupo se moverán a sin grupo. Esta operación no se puede deshacer.
+ toast_success: Eliminación exitosa, {total} adjuntos se han movido a sin grupo
+ delete_with_attachments:
+ button: También eliminar adjuntos
+ title: ¿Estás seguro de que deseas eliminar este grupo?
+ description: Al eliminar el grupo y todos los adjuntos dentro de él, esta acción no se puede deshacer.
+ toast_success: Eliminación exitosa, {total} adjuntos se han eliminado simultáneamente
+ policies_modal:
+ title: Políticas de Almacenamiento
+ empty:
+ title: Actualmente no hay estrategias de almacenamiento disponibles.
+ message: No hay políticas de almacenamiento disponibles en este momento. Puedes intentar actualizar o crear una nueva política.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar esta política?
+ description: No hay adjuntos cargados bajo la política actual.
+ can_not_delete:
+ title: Fallo en la eliminación
+ description: Hay adjuntos bajo esta política, que no se pueden eliminar.
+ policy_editing_modal:
+ titles:
+ create: "Nueva política: {policy_template}"
+ update: "Editar política: {policy}"
+ fields:
+ display_name:
+ label: Nombre para Mostrar
+ upload_modal:
+ title: Cargar adjunto
+ filters:
+ group:
+ label: "Seleccionar grupo:"
+ policy:
+ label: "Seleccionar política de almacenamiento:"
+ empty:
+ title: Sin política de almacenamiento
+ description: Antes de cargar, es necesario crear una nueva política de almacenamiento.
+ not_select: Por favor, selecciona una política de almacenamiento primero
+ select_modal:
+ title: Seleccionar adjunto
+ providers:
+ default:
+ label: Adjuntos
+ operations:
+ select:
+ result: "({count} elementos seleccionados)"
+ theme:
+ title: Temas
+ common:
+ buttons:
+ install: Instalar Tema
+ tabs:
+ detail: Detalles
+ actions:
+ management: Gestión de Temas
+ empty:
+ title: No hay temas activados o seleccionados actualmente.
+ message: Puedes cambiar de tema o instalar nuevos.
+ actions:
+ switch: Cambiar de Tema
+ operations:
+ active:
+ title: ¿Estás seguro de activar el tema actual?
+ toast_success: Tema activado exitosamente
+ reset:
+ title: ¿Estás seguro de que deseas restablecer todas las configuraciones del tema?
+ description: Esta operación eliminará la configuración guardada y la restablecerá a los ajustes predeterminados.
+ toast_success: Configuración restablecida exitosamente
+ reload:
+ button: Recargar
+ title: ¿Estás seguro de que deseas recargar todas las configuraciones del tema?
+ description: Esta operación solo recargará la configuración del tema y la definición del formulario de ajustes, y no eliminará ninguna configuración guardada.
+ toast_success: Recarga de configuración exitosa
+ uninstall:
+ title: ¿Estás seguro de que deseas desinstalar este tema?
+ uninstall_and_delete_config:
+ button: Desinstalar y eliminar configuración
+ title: ¿Estás seguro de que deseas desinstalar este tema y su configuración correspondiente?
+ remote_download:
+ title: Se ha detectado una dirección de descarga remota, ¿deseas descargar?
+ description: "Por favor, verifica cuidadosamente si esta dirección es confiable: {url}"
+ upload_modal:
+ titles:
+ install: Instalar tema
+ upgrade: Actualizar tema ({display_name})
+ operations:
+ existed_during_installation:
+ title: El tema ya existe.
+ description: El tema instalado actualmente ya existe, deseas actualizarlo?
+ tabs:
+ local: Local
+ remote:
+ title: Remoto
+ fields:
+ url: URL Remota
+ list_modal:
+ titles:
+ installed_themes: Temas Instalados
+ not_installed_themes: Temas no Instalados
+ tabs:
+ installed: Instalados
+ not_installed: No Instalados
+ empty:
+ title: No hay temas instalados actualmente.
+ message: No hay temas instalados actualmente, puedes intentar actualizar o instalar un nuevo tema.
+ not_installed_empty:
+ title: No hay temas actualmente no instalados.
+ preview_model:
+ title: "Vista Previa del Tema: {display_name}"
+ actions:
+ switch: Cambiar de tema
+ setting: Ajustes
+ open: Abrir
+ detail:
+ fields:
+ author: Autor
+ website: Sitio Web
+ repo: Repositorio Fuente
+ version: Versión
+ requires: Requiere
+ storage_location: Ubicación de Almacenamiento
+ plugin_requires: Requiere Plugin
+ settings:
+ title: Ajustes del Tema
+ custom_templates:
+ default: Predeterminado
+ menu:
+ title: Menús
+ empty:
+ title: Actualmente no hay menús.
+ message: Puedes intentar actualizar o crear un nuevo menú.
+ menu_item_empty:
+ title: Actualmente no hay elementos de menú.
+ message: Puedes intentar actualizar o crear un nuevo elemento de menú.
+ operations:
+ set_primary:
+ button: Establecer como menú principal
+ toast_success: Configuración exitosa
+ delete_menu:
+ title: "¿Estás seguro de que deseas eliminar este menú?"
+ description: Todos los elementos de menú de este menú se eliminarán al mismo tiempo y esta operación no se puede deshacer.
+ delete_menu_item:
+ title: "¿Estás seguro de que deseas eliminar este elemento de menú?"
+ description: Todos los subelementos de menú se eliminarán al mismo tiempo y no se pueden restaurar después de la eliminación.
+ add_sub_menu_item:
+ button: Agregar subelemento de menú
+ list:
+ fields:
+ primary: Principal
+ items_count: "{count} elementos"
+ menu_editing_modal:
+ titles:
+ create: Crear menú
+ update: Actualizar menú
+ fields:
+ display_name:
+ label: Nombre para mostrar
+ menu_item_editing_modal:
+ titles:
+ create: Crear elemento de menú
+ update: Actualizar elemento de menú
+ groups:
+ general: General
+ annotations: Anotaciones
+ fields:
+ parent:
+ label: Padre
+ placeholder: Selecciona el elemento de menú padre
+ ref_kind:
+ label: Tipo
+ placeholder: "Por favor selecciona {label}"
+ options:
+ custom: Personalizado
+ post: Publicación
+ single_page: Página
+ category: Categoría
+ tag: Etiqueta
+ display_name:
+ label: Nombre para mostrar
+ href:
+ label: Dirección del enlace
+ target:
+ label: Destino
+ options:
+ self: _self
+ blank: _blank
+ parent: _parent
+ top: _top
+ plugin:
+ title: Plugins
+ tabs:
+ detail: Detail
+ empty:
+ title: There are no installed plugins currently.
+ message: There are no installed plugins currently, you can try refreshing or installing new plugins.
+ actions:
+ install: Install Plugin
+ operations:
+ reset:
+ title: Are you sure you want to reset all configurations of the plugin?
+ description: This operation will delete the saved configuration and reset it to default settings.
+ toast_success: Reset configuration successfully
+ uninstall:
+ title: Are you sure you want to uninstall this plugin?
+ uninstall_and_delete_config:
+ title: Are you sure you want to uninstall this plugin and its corresponding configuration?
+ uninstall_when_enabled:
+ confirm_text: Stop running and uninstall
+ description: The current plugin is still in the enabled state and will be uninstalled after it stops running. This operation cannot be undone.
+ change_status:
+ active_title: Are you sure you want to active this plugin?
+ inactive_title: Are you sure you want to inactive this plugin?
+ remote_download:
+ title: Remote download address detected, do you want to download?
+ description: "Please carefully verify whether this address can be trusted: {url}"
+ filters:
+ status:
+ items:
+ active: Active
+ inactive: Inactive
+ sort:
+ items:
+ create_time_desc: Latest Installed
+ create_time_asc: Earliest Installed
+ list:
+ actions:
+ uninstall_and_delete_config: Uninstall and delete config
+ upload_modal:
+ titles:
+ install: Install plugin
+ upgrade: Upgrade plugin ({display_name})
+ tabs:
+ local: Local
+ remote:
+ title: Remote
+ fields:
+ url: Remote URL
+ operations:
+ active_after_install:
+ title: Install successful
+ description: Would you like to activate the currently installed plugin?
+ existed_during_installation:
+ title: The plugin already exists.
+ description: The currently installed plugin already exists, do you want to upgrade?
+ detail:
+ title: Plugin detail
+ header:
+ title: Plugin information
+ fields:
+ display_name: Display Name
+ description: Description
+ version: Version
+ requires: Requires
+ author: Author
+ license: License
+ role_templates: Role Templates
+ last_starttime: Last Start Time
+ loader:
+ toast:
+ entry_load_failed: "{name}: Failed to load plugin entry file"
+ style_load_failed: "{name}: Failed to load plugin stylesheet file"
+ extension_points:
+ editor:
+ providers:
+ default: Default Editor
+ user:
+ title: Usuarios
+ actions:
+ roles: Roles
+ identity_authentication: Autenticación de Identidad
+ empty:
+ title: Actualmente no hay usuarios que cumplan con los criterios de filtrado.
+ message: No hay usuarios que coincidan con los criterios de filtrado en este momento. Puedes intentar actualizar o crear un nuevo usuario.
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar a este usuario?
+ delete_in_batch:
+ title: ¿Estás seguro de que deseas eliminar a los usuarios seleccionados?
+ update_profile:
+ title: Actualizar perfil
+ change_password:
+ title: Cambiar contraseña
+ grant_permission:
+ title: Conceder permiso
+ filters:
+ role:
+ label: Rol
+ result: "Rol: {role}"
+ sort:
+ items:
+ create_time_desc: Últimos creados
+ create_time_asc: Más antiguos creados
+ editing_modal:
+ titles:
+ update: Editar usuario
+ create: Crear usuario
+ groups:
+ general: General
+ annotations: Anotaciones
+ fields:
+ username:
+ label: Nombre de usuario
+ validation: Por favor, introduce un nombre de usuario válido.
+ display_name:
+ label: Nombre para mostrar
+ email:
+ label: Correo electrónico
+ phone:
+ label: Teléfono
+ avatar:
+ label: Avatar
+ bio:
+ label: Biografía
+ change_password_modal:
+ title: Cambiar contraseña
+ fields:
+ new_password:
+ label: Nueva contraseña
+ confirm_password:
+ label: Confirmar contraseña
+ grant_permission_modal:
+ title: Conceder permiso
+ fields:
+ role:
+ label: Rol
+ placeholder: Por favor, selecciona un rol
+ detail:
+ title: Detalles del usuario
+ tabs:
+ detail: Detalle
+ actions:
+ update_profile:
+ title: Actualizar perfil
+ change_password:
+ title: Cambiar contraseña
+ operations:
+ bind:
+ button: Vincular
+ unbind:
+ button: Desvincular
+ title: ¿Estás seguro de que deseas desvincular el método de inicio de sesión para {display_name}?
+ fields:
+ display_name: Nombre para mostrar
+ username: Nombre de usuario
+ email: Correo electrónico
+ roles: Roles
+ bio: Biografía
+ creation_time: Fecha de creación
+ identity_authentication: Autenticación de identidad
+ avatar:
+ title: Avatar
+ toast_upload_failed: No se pudo cargar el avatar
+ toast_remove_failed: No se pudo eliminar el avatar
+ cropper_modal:
+ title: Recortar Avatar
+ remove:
+ title: ¿Estás seguro de que deseas eliminar el avatar?
+ tooltips:
+ upload: Cargar
+ zoom_in: Acercar
+ zoom_out: Alejar
+ flip_horizontal: Voltear Horizontalmente
+ flip_vertical: Voltear Verticalmente
+ reset: Restablecer
+ role:
+ title: Roles
+ common:
+ text:
+ contains_all_permissions: Contiene todos los permisos
+ contains_n_permissions: Contiene {count} permisos
+ system_reserved: Reservado del Sistema
+ custom: Personalizado
+ dependent_on: Dependiente de {roles}
+ provided_by_plugin: Proporcionado por {plugin}
+ operations:
+ delete:
+ title: ¿Estás seguro de que deseas eliminar este rol?
+ description: Una vez eliminado el rol, se eliminarán las asignaciones de rol de los usuarios asociados y esta operación no se puede deshacer.
+ create_based_on_this_role:
+ button: Crear basado en este rol
+ detail:
+ title: Detalle del rol
+ header:
+ title: Información del rol
+ tabs:
+ detail: Detalle
+ permissions: Permisos
+ fields:
+ display_name: Nombre para mostrar
+ name: Nombre
+ type: Tipo
+ creation_time: Fecha de creación
+ permissions_detail:
+ system_reserved_alert:
+ description: El rol reservado del sistema no admite modificaciones. Se recomienda crear un nuevo rol basado en este.
+ editing_modal:
+ titles:
+ create: Crear rol
+ update: Actualizar rol
+ groups:
+ general: General
+ permissions: Permisos
+ fields:
+ display_name: Nombre para mostrar
+ identity_authentication:
+ title: Autenticación de Identidad
+ tabs:
+ detail: Detalle
+ setting: Configuración
+ operations:
+ enable:
+ title: ¿Estás seguro de que deseas habilitar este método de autenticación de identidad?
+ disable:
+ title: ¿Estás seguro de que deseas deshabilitar este método de autenticación de identidad?
+ disable_privileged:
+ tooltip: El método de autenticación reservado por el sistema no se puede deshabilitar
+ detail:
+ title: Detalle de la autenticación de identidad
+ fields:
+ display_name: Nombre para mostrar
+ description: Descripción
+ website: Sitio web
+ help_page: Página de ayuda
+ authentication_url: URL de inicio de sesión
+ setting:
+ title: Configuraciones
+ actuator:
+ title: Actuador
+ actions:
+ copy:
+ toast_browser_not_supported: El navegador actual no admite la función de copiado
+ header:
+ titles:
+ general: Información general
+ environment: Información del entorno
+ fields:
+ external_url: URL externa
+ start_time: Hora de inicio
+ timezone: Zona horaria
+ locale: Idioma
+ version: Versión
+ build_time: Fecha de compilación
+ database: Base de datos
+ os: Sistema operativo
+ log: Registro del sistema
+ fields_values:
+ external_url:
+ not_setup: No configurado
+ copy_results:
+ external_url: "URL externa: {external_url}"
+ start_time: "Hora de inicio: {start_time}"
+ version: "Versión: {version}"
+ build_time: "Fecha de compilación: {build_time}"
+ database: "Base de datos: {database}"
+ os: "Sistema operativo: {os}"
+ alert:
+ external_url_invalid: La URL de acceso externo detectada no coincide con la URL de acceso actual, lo que podría causar que algunos enlaces no se redirijan correctamente. Por favor, verifica la configuración de la URL de acceso externo.
+ backup:
+ title: Copia de Seguridad y Restauración
+ tabs:
+ backup_list: Copias de Seguridad
+ restore: Restaurar
+ empty:
+ title: Aún no se han creado copias de seguridad
+ message: Puedes hacer clic en el botón debajo para crear una copia de seguridad
+ operations:
+ create:
+ button: Crear copia de seguridad
+ title: Crear copia de seguridad
+ description: ¿Estás seguro de que deseas crear una copia de seguridad? Esta operación puede tomar un tiempo.
+ toast_success: Solicitud de creación de copia de seguridad realizada
+ delete:
+ title: Eliminar copia de seguridad
+ description: ¿Estás seguro de que deseas eliminar esta copia de seguridad?
+ restore:
+ title: Restauración exitosa
+ description: Después de una restauración exitosa, necesitas reiniciar Halo para cargar los recursos del sistema normalmente. Después de hacer clic en OK, reiniciaremos automáticamente Halo.
+ restart:
+ toast_success: Solicitud de reinicio realizada
+ list:
+ phases:
+ pending: Pendiente
+ running: En Progreso
+ succeeded: Exitosa
+ failed: Fallida
+ fields:
+ expiresAt: Expira el {expiresAt}
+ restore:
+ tips:
+ first: 1. El proceso de restauración puede tomar un tiempo, por favor no actualices la página durante este período.
+ second: 2. Durante el proceso de restauración, aunque los datos existentes no se eliminarán, si hay un conflicto, los datos se sobrescribirán.
+ third: 3. Después de completar la restauración, necesitas reiniciar Halo para cargar los recursos del sistema normalmente.
+ complete: Restauración completada, esperando reinicio...
+ start: Iniciar Restauración
+ exception:
+ not_found:
+ message: Página no encontrada
+ forbidden:
+ message: Acceso no autorizado a esta página
+ actions:
+ home: Ir a la página de inicio
+ setup:
+ title: Configuración
+ operations:
+ submit:
+ button: Configurar
+ toast_success: Configuración exitosa
+ setup_initial_data:
+ loading: Inicializando datos, por favor espera...
+ fields:
+ site_title:
+ label: Título del Sitio
+ email:
+ label: Correo Electrónico
+ username:
+ label: Nombre de Usuario
+ password:
+ label: Contraseña
+ confirm_password:
+ label: Confirmar Contraseña
+ rbac:
+ Attachments Management: Gestión de archivos adjuntos
+ Attachment Manage: Gestor de adjuntos
+ Attachment View: Vista de adjuntos
+ role-template-view-attachments: Vista de adjuntos
+ Comments Management: Comentarios
+ Comment Manage: Gestor de comentarios
+ Comment View: Vista de comentarios
+ role-template-view-comments: Vista de comentarios
+ ConfigMaps Management: ConfigMaps
+ ConfigMap Manage: Gestor de ConfigMaps
+ ConfigMap View: Vista de ConfigMaps
+ role-template-view-configmaps: Vista de ConfigMaps
+ Menus Management: Menús
+ Menu Manage: Gestor de menús
+ Menu View: Vista de menús
+ role-template-view-menus: Vista de menús
+ Permissions Management: Permisos
+ Permissions Manage: Gestor de permisos
+ Permissions View: Vista de permisos
+ role-template-view-permissions: Vista de permisos
+ role-template-manage-permissions: Gestor de permisos
+ Plugins Management: Plugins
+ Plugin Manage: Gestor de plugins
+ Plugin View: Vista de plugins
+ role-template-view-plugins: Vista de plugins
+ Posts Management: Publicaciones
+ Post Manage: Gestor de publicaciones
+ Post View: Vista de publicaciones
+ role-template-view-posts: Vista de publicaciones
+ role-template-manage-snapshots: Gestor de snapshots
+ role-template-view-snapshots: Vista de snapshots
+ role-template-manage-tags: Gestor de etiquetas
+ role-template-view-tags: Vista de etiquetas
+ role-template-manage-categories: Gestor de categorías
+ role-template-view-categories: Vista de categorías
+ Roles Management: Roles
+ Role Manage: Gestor de roles
+ Role View: Vista de roles
+ role-template-view-roles: Vista de roles
+ Settings Management: Configuración
+ Setting Manage: Gestor de configuración
+ Setting View: Vista de configuración
+ role-template-view-settings: Vista de configuración
+ SinglePages Management: SinglePages
+ SinglePage Manage: Gestor de SinglePages
+ SinglePage View: Vista de SinglePages
+ role-template-view-singlepages: Vista de SinglePages
+ Themes Management: Temas
+ Theme Manage: Gestor de temas
+ Theme View: Vista de temas
+ role-template-view-themes: Vista de temas
+ Users Management: Usuarios
+ User manage: Gestor de usuarios
+ User View: Vista de usuarios
+ Migration Management: Copia de seguridad y restauración
+ Migration Manage: Gestor de copia de seguridad y restauración
+ role-template-view-users: Vista de usuarios
+ role-template-change-password: Cambiar contraseña
+ components:
+ submit_button:
+ computed_text: "{text} ({shortcut})"
+ annotations_form:
+ custom_fields:
+ label: Personalizado
+ validation: La clave actual ya está en uso
+ default_editor:
+ tabs:
+ toc:
+ title: Índice
+ empty: No hay índice disponible
+ detail:
+ title: Detalle
+ fields:
+ character_count: Conteo de caracteres
+ word_count: Conteo de palabras
+ publish_time: Hora de publicación
+ draft: Borrador
+ owner: Propietario
+ permalink: Enlace permanente
+ extensions:
+ placeholder:
+ options:
+ placeholder: "Ingresa / para seleccionar el tipo de entrada."
+ toolbox:
+ attachment: Adjunto
+ global_search:
+ placeholder: Ingresa palabras clave para buscar
+ no_results: Sin resultados de búsqueda
+ buttons:
+ select: Seleccionar
+ groups:
+ console: Página de la consola
+ user: Usuario
+ plugin: Plugin
+ post: Publicación
+ category: Categoría
+ tag: Etiqueta
+ page: Página
+ attachment: Adjunto
+ setting: Configuración
+ theme_setting: Configuración del tema
+ pagination:
+ page_label: página
+ size_label: elementos por página
+ total_label: Total de {total} elementos
+ social_auth_providers:
+ title: Inicio de sesión de terceros
+ app_download_alert:
+ description: "Los temas y complementos para Halo se pueden descargar en las siguientes direcciones:"
+ sources:
+ app_store: "Tienda de aplicaciones oficial: {url}"
+ github: "GitHub: {url}"
+ composables:
+ content_cache:
+ toast_recovered: Contenido no guardado recuperado de la caché
+ formkit:
+ category_select:
+ creation_label: "Crear categoría {text}"
+ tag_select:
+ creation_label: "Crear etiqueta {text}"
+ validation:
+ trim: Por favor, elimina los espacios al inicio y al final
+ common:
+ buttons:
+ save: Guardar
+ close: Cerrar
+ close_and_shortcut: Cerrar (Esc)
+ delete: Borrar
+ setting: Configuración
+ confirm: Confirmar
+ cancel: Cancelar
+ cancel_and_shortcut: Cancelar (Esc)
+ new: Nuevo
+ edit: Editar
+ back: Volver
+ refresh: Actualizar
+ publish: Publicar
+ cancel_publish: Cancelar Publicación
+ next: Siguiente
+ previous: Anterior
+ install: Instalar
+ uninstall: Desinstalar
+ upgrade: Actualizar
+ reset: Reiniciar
+ preview: Vista previa
+ recovery: Recuperar
+ delete_permanently: Borrar permanentemente
+ active: Activar
+ download: Descargar
+ copy: Copiar
+ upload: Subir
+ add: Agregar
+ submit: Enviar
+ detail: Detalle
+ radio:
+ "yes": Sí
+ "no": No
+ select:
+ public: Público
+ private: Privado
+ placeholder:
+ search: Ingresa palabras clave para buscar
+ toast:
+ operation_success: Operación realizada con éxito
+ delete_success: Eliminado exitosamente
+ save_success: Guardado exitosamente
+ publish_success: Publicado exitosamente
+ cancel_publish_success: Publicación cancelada exitosamente
+ recovery_success: Recuperado exitosamente
+ uninstall_success: Desinstalado exitosamente
+ active_success: Activado exitosamente
+ inactive_success: Desactivado exitosamente
+ upgrade_success: Actualizado exitosamente
+ install_success: Instalado exitosamente
+ download_success: Descargado exitosamente
+ copy_success: Copiado exitosamente
+ operation_failed: Fallo en la operación
+ download_failed: Fallo en la descarga
+ save_failed_and_retry: "Fallo al guardar, por favor intenta nuevamente"
+ publish_failed_and_retry: "Fallo al publicar, por favor intenta nuevamente"
+ network_error: "Error de red, por favor verifica tu conexión"
+ login_expired: "Sesión expirada, por favor inicia sesión nuevamente"
+ forbidden: Acceso denegado
+ not_found: Recurso no encontrado
+ server_internal_error: Error interno del servidor
+ unknown_error: Error desconocido
+ dialog:
+ titles:
+ tip: Consejo
+ warning: Advertencia
+ descriptions:
+ cannot_be_recovered: Esta operación es irreversible.
+ editor_not_found: No se encontró ningún editor que coincida con el formato {raw_type}. Por favor verifica si el complemento del editor ha sido instalado.
+ filters:
+ results:
+ keyword: "Palabra clave: {keyword}"
+ sort: "Ordenar: {sort}"
+ status: "Estado: {status}"
+ labels:
+ sort: Ordenar
+ status: Estado
+ item_labels:
+ all: Todo
+ default: Por defecto
+ status:
+ deleting: Borrando
+ loading: Cargando
+ loading_error: Error al cargar
+ activated: Activado
+ not_activated: No activado
+ installed: Instalado
+ not_installed: No instalado
+ text:
+ none: Ninguno
+ tip: Consejo
+ warning: Advertencia
+ tooltips:
+ unpublished_content_tip: Hay contenido que ha sido guardado pero aún no ha sido publicado.
+ publishing: Publicando
+ recovering: Recuperando
+ fields:
+ post_count: "{count} Publicaciones"
diff --git a/console/src/utils/api-client.ts b/console/src/utils/api-client.ts
index a0871441a7e..217defaab5a 100644
--- a/console/src/utils/api-client.ts
+++ b/console/src/utils/api-client.ts
@@ -45,6 +45,7 @@ import {
NotificationHaloRunV1alpha1NotifierDescriptorApi,
ApiSecurityHaloRunV1alpha1PersonalAccessTokenApi,
SecurityHaloRunV1alpha1PersonalAccessTokenApi,
+ ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi,
UcApiContentHaloRunV1alpha1AttachmentApi,
UcApiContentHaloRunV1alpha1PostApi,
UcApiContentHaloRunV1alpha1SnapshotApi,
@@ -247,6 +248,11 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
+ twoFactor: new ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi(
+ undefined,
+ baseURL,
+ axios
+ ),
uc: {
post: new UcApiContentHaloRunV1alpha1PostApi(undefined, baseURL, axios),
attachment: new UcApiContentHaloRunV1alpha1AttachmentApi(
diff --git a/console/uc-src/modules/profile/Profile.vue b/console/uc-src/modules/profile/Profile.vue
index 0ed9097b997..2eb11e5f27e 100644
--- a/console/uc-src/modules/profile/Profile.vue
+++ b/console/uc-src/modules/profile/Profile.vue
@@ -22,6 +22,7 @@ import DetailTab from "./tabs/Detail.vue";
import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue";
import { useRouteQuery } from "@vueuse/router";
import NotificationPreferences from "./tabs/NotificationPreferences.vue";
+import TwoFactor from "./tabs/TwoFactor.vue";
const { t } = useI18n();
@@ -79,6 +80,12 @@ const tabs: UserTab[] = [
component: markRaw(PersonalAccessTokensTab),
priority: 30,
},
+ {
+ id: "2fa",
+ label: "两步验证",
+ component: markRaw(TwoFactor),
+ priority: 40,
+ },
];
const tabbarItems = computed(() => {
diff --git a/console/uc-src/modules/profile/tabs/TwoFactor.vue b/console/uc-src/modules/profile/tabs/TwoFactor.vue
new file mode 100644
index 00000000000..63ead42d7ab
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/TwoFactor.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+ -
+ 验证方式
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ settings?.totpConfigured ? "重新配置" : "配置" }}
+
+
+ 停用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue b/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue
new file mode 100644
index 00000000000..115033063fe
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue b/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue
new file mode 100644
index 00000000000..ca02cb544c9
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
使用验证器应用扫描下方二维码:
+
+
+
+ 如果无法扫描二维码,点击查看代替步骤
+
+
+
+ 使用以下代码手动配置验证器应用:
+
+
+
+ {{ data?.rawSecret }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 完成
+
+ 关闭
+
+
+
+
diff --git a/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue b/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue
new file mode 100644
index 00000000000..5c013e54fb2
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ 停用
+
+ 关闭
+
+
+
+
diff --git a/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue b/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue
new file mode 100644
index 00000000000..f6070aaf7c6
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ 停用
+
+ 关闭
+
+
+
+
diff --git a/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue b/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue
new file mode 100644
index 00000000000..62204095435
--- /dev/null
+++ b/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+ 启用
+
+ 关闭
+
+
+
+
diff --git a/platform/application/build.gradle b/platform/application/build.gradle
index 375415cc2f7..7905d86503a 100644
--- a/platform/application/build.gradle
+++ b/platform/application/build.gradle
@@ -21,6 +21,7 @@ ext {
springDocOpenAPI = "2.3.0"
lucene = "9.7.0"
resilience4jVersion = "2.0.2"
+ twoFactorAuth = "1.3"
}
javaPlatform {
@@ -52,6 +53,7 @@ dependencies {
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion"
api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion"
+ api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth"
runtime 'org.mariadb:r2dbc-mariadb:1.1.4'
}