mirror of https://github.com/halo-dev/halo
Merge pull request #4737 from JohnNiang/feat/mfa
Support TOTP two-factor authenticationpull/5200/head
commit
3de60dd938
|
@ -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'
|
||||
|
|
|
@ -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 class User extends AbstractExtension {
|
|||
|
||||
private Boolean twoFactorAuthEnabled;
|
||||
|
||||
private String totpEncryptedSecret;
|
||||
|
||||
private Boolean disabled;
|
||||
|
||||
private Integer loginHistoryLimit;
|
||||
|
|
|
@ -31,7 +31,6 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||
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;
|
||||
|
@ -42,6 +41,7 @@ import run.halo.app.security.authentication.login.impl.RsaKeyService;
|
|||
import run.halo.app.security.authentication.pat.PatAuthenticationManager;
|
||||
import run.halo.app.security.authentication.pat.PatJwkSupplier;
|
||||
import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager;
|
||||
import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
|
||||
|
||||
/**
|
||||
|
@ -67,7 +67,11 @@ public class WebServerSecurityConfig {
|
|||
http.securityMatcher(pathMatchers("/api/**", "/apis/**", "/oauth2/**",
|
||||
"/login/**", "/logout", "/actuator/**"))
|
||||
.authorizeExchange(spec -> {
|
||||
spec.anyExchange().access(new RequestInfoAuthorizationManager(roleService));
|
||||
spec.anyExchange().access(
|
||||
new TwoFactorAuthorizationManager(
|
||||
new RequestInfoAuthorizationManager(roleService)
|
||||
)
|
||||
);
|
||||
})
|
||||
.anonymous(spec -> {
|
||||
spec.authorities(AnonymousUserConst.Role);
|
||||
|
@ -79,12 +83,11 @@ public class WebServerSecurityConfig {
|
|||
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()
|
||||
|
|
|
@ -82,11 +82,13 @@ import run.halo.app.extension.Metadata;
|
|||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.infra.AnonymousUserConst;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.ValidationUtils;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
|
@ -542,10 +544,11 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
@NonNull
|
||||
Mono<ServerResponse> me(ServerRequest request) {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.flatMap(ctx -> {
|
||||
var name = ctx.getAuthentication().getName();
|
||||
return userService.getUser(name);
|
||||
})
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.filter(obj -> !(obj instanceof TwoFactorAuthentication))
|
||||
.map(Authentication::getName)
|
||||
.defaultIfEmpty(AnonymousUserConst.PRINCIPAL)
|
||||
.flatMap(userService::getUser)
|
||||
.flatMap(this::toDetailedUser)
|
||||
.flatMap(user -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
* <p>Obtain the authorities from the authenticated authentication and construct it as a RoleBinding
|
||||
* list.</p>
|
||||
* <p>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.</p>
|
||||
* <p>For tokens in other formats, after authentication, fill the authorities with the token into
|
||||
* the SecurityContextHolder.</p>
|
||||
*
|
||||
* @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<String> 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());
|
||||
}
|
||||
}
|
|
@ -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<String> listBoundRoleNames(Collection<? extends GrantedAuthority> authorities);
|
||||
|
||||
}
|
|
@ -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<String> collectAllErrors(MessageSource messageSource, Locale locale) {
|
||||
var globalErrors = resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale);
|
||||
var fieldErrors = resolveErrors(bindingResult.getFieldErrors(), messageSource, locale);
|
||||
var errors = new ArrayList<String>(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<String> 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();
|
||||
}
|
||||
}
|
|
@ -2,8 +2,12 @@ package run.halo.app.security;
|
|||
|
||||
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.RoleService;
|
|||
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 class DefaultUserDetailService
|
|||
.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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
|
||||
@Component
|
||||
public class ExceptionSecurityConfigurer implements SecurityConfigurer {
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
http.exceptionHandling(exception -> {
|
||||
var accessDeniedHandler = new BearerTokenServerAccessDeniedHandler();
|
||||
var entryPoint = new DefaultServerAuthenticationEntryPoint();
|
||||
exception
|
||||
.authenticationEntryPoint(entryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Void> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.GrantedAuthority;
|
|||
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 @@ import run.halo.app.security.authorization.AuthorityUtils;
|
|||
* @author johnniang
|
||||
*/
|
||||
public class JwtScopesAndRolesGrantedAuthoritiesConverter
|
||||
implements Converter<Jwt, Flux<GrantedAuthority>> {
|
||||
implements Converter<Jwt, Flux<GrantedAuthority>> {
|
||||
|
||||
private final Converter<Jwt, Collection<GrantedAuthority>> delegate;
|
||||
|
||||
|
@ -28,17 +30,23 @@ public class JwtScopesAndRolesGrantedAuthoritiesConverter
|
|||
@Override
|
||||
public Flux<GrantedAuthority> convert(Jwt jwt) {
|
||||
var grantedAuthorities = new ArrayList<GrantedAuthority>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||
return logoutPageGeneratingWebFilter.filter(exchange, chain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING.getOrder();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,27 +2,38 @@ package run.halo.app.security.authentication.login;
|
|||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||
import java.util.Base64;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.IpAddressUtils;
|
||||
|
||||
@Slf4j
|
||||
public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter {
|
||||
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
public LoginAuthenticationConverter(CryptoService cryptoService) {
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
public LoginAuthenticationConverter(CryptoService cryptoService,
|
||||
RateLimiterRegistry rateLimiterRegistry) {
|
||||
this.cryptoService = cryptoService;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||
return super.convert(exchange)
|
||||
// validate the password
|
||||
.flatMap(token -> {
|
||||
.<Authentication>flatMap(token -> {
|
||||
var credentials = (String) token.getCredentials();
|
||||
byte[] credentialsBytes;
|
||||
try {
|
||||
|
@ -37,6 +48,23 @@ public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationC
|
|||
.map(decryptedCredentials -> new UsernamePasswordAuthenticationToken(
|
||||
token.getPrincipal(),
|
||||
new String(decryptedCredentials, UTF_8)));
|
||||
});
|
||||
})
|
||||
.transformDeferred(createIpBasedRateLimiter(exchange))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
|
||||
}
|
||||
|
||||
private <T> RateLimiterOperator<T> createIpBasedRateLimiter(ServerWebExchange exchange) {
|
||||
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
|
||||
var rateLimiter = rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp,
|
||||
"authentication");
|
||||
if (log.isDebugEnabled()) {
|
||||
var metrics = rateLimiter.getMetrics();
|
||||
log.debug(
|
||||
"Authentication with Rate Limiter: {}, available permissions: {}, number of "
|
||||
+ "waiting threads: {}",
|
||||
rateLimiter, metrics.getAvailablePermissions(),
|
||||
metrics.getNumberOfWaitingThreads());
|
||||
}
|
||||
return RateLimiterOperator.of(rateLimiter);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,6 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static run.halo.app.infra.exception.Exceptions.createErrorResponse;
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
@ -17,29 +10,17 @@ import org.springframework.security.authentication.ObservationReactiveAuthentica
|
|||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.CredentialsContainer;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
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.ServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.IpAddressUtils;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
|
||||
|
@ -53,8 +34,6 @@ import run.halo.app.security.AdditionalWebFilter;
|
|||
@Component
|
||||
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final ObservationRegistry observationRegistry;
|
||||
|
||||
private final ReactiveUserDetailsService userDetailsService;
|
||||
|
@ -69,9 +48,6 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
|
||||
private final AuthenticationWebFilter authenticationWebFilter;
|
||||
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
|
||||
public UsernamePasswordAuthenticator(ServerResponse.Context context,
|
||||
|
@ -80,20 +56,17 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
||||
RateLimiterRegistry rateLimiterRegistry, MessageSource messageSource,
|
||||
ExtensionGetter extensionGetter) {
|
||||
this.context = context;
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.passwordService = passwordService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
this.cryptoService = cryptoService;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
this.messageSource = messageSource;
|
||||
this.extensionGetter = extensionGetter;
|
||||
|
||||
this.authenticationWebFilter =
|
||||
new UsernamePasswordAuthenticationWebFilter(authenticationManager());
|
||||
configureAuthenticationWebFilter(this.authenticationWebFilter);
|
||||
this.authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
|
||||
configureAuthenticationWebFilter(this.authenticationWebFilter, context, messageSource,
|
||||
rateLimiterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -107,13 +80,17 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
return SecurityWebFiltersOrder.FORM_LOGIN.getOrder();
|
||||
}
|
||||
|
||||
void configureAuthenticationWebFilter(AuthenticationWebFilter filter) {
|
||||
void configureAuthenticationWebFilter(AuthenticationWebFilter filter,
|
||||
ServerResponse.Context context,
|
||||
MessageSource messageSource,
|
||||
RateLimiterRegistry rateLimiterRegistry) {
|
||||
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
|
||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
||||
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
|
||||
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
||||
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
|
||||
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
|
||||
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService
|
||||
));
|
||||
filter.setAuthenticationFailureHandler(handler);
|
||||
filter.setAuthenticationSuccessHandler(handler);
|
||||
filter.setServerAuthenticationConverter(authConverter);
|
||||
filter.setSecurityContextRepository(securityContextRepository);
|
||||
}
|
||||
|
||||
|
@ -130,121 +107,4 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
return manager;
|
||||
}
|
||||
|
||||
private <T> RateLimiterOperator<T> createIPBasedRateLimiter(ServerWebExchange exchange) {
|
||||
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
|
||||
var rateLimiter =
|
||||
rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp,
|
||||
"authentication");
|
||||
if (log.isDebugEnabled()) {
|
||||
var metrics = rateLimiter.getMetrics();
|
||||
log.debug(
|
||||
"Authentication with Rate Limiter: {}, available permissions: {}, number of "
|
||||
+ "waiting threads: {}",
|
||||
rateLimiter, metrics.getAvailablePermissions(),
|
||||
metrics.getNumberOfWaitingThreads());
|
||||
}
|
||||
return RateLimiterOperator.of(rateLimiter);
|
||||
}
|
||||
|
||||
private Mono<Void> handleRateLimitExceededException(RateLimitExceededException e,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(e, null, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> handleAuthenticationException(AuthenticationException exception,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> writeErrorResponse(ErrorResponse errorResponse,
|
||||
ServerWebExchange exchange) {
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
}
|
||||
|
||||
private class UsernamePasswordAuthenticationWebFilter extends AuthenticationWebFilter {
|
||||
|
||||
public UsernamePasswordAuthenticationWebFilter(
|
||||
ReactiveAuthenticationManager authenticationManager) {
|
||||
super(authenticationManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<Void> onAuthenticationSuccess(Authentication authentication,
|
||||
WebFilterExchange webFilterExchange) {
|
||||
return super.onAuthenticationSuccess(authentication, webFilterExchange)
|
||||
.transformDeferred(createIPBasedRateLimiter(webFilterExchange.getExchange()))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
|
||||
.onErrorResume(RateLimitExceededException.class,
|
||||
e -> handleRateLimitExceededException(e, webFilterExchange.getExchange()));
|
||||
}
|
||||
}
|
||||
|
||||
public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
|
||||
|
||||
private final ServerAuthenticationSuccessHandler defaultHandler =
|
||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||
Authentication authentication) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.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();
|
||||
}
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(principal)
|
||||
.flatMap(serverResponse ->
|
||||
serverResponse.writeTo(exchange, context));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login failure.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class LoginFailureHandler implements ServerAuthenticationFailureHandler {
|
||||
|
||||
private final ServerAuthenticationFailureHandler defaultHandler =
|
||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||
|
||||
public LoginFailureHandler() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(defaultHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange))
|
||||
.transformDeferred(createIPBasedRateLimiter(exchange))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
|
||||
.onErrorResume(RateLimitExceededException.class,
|
||||
e -> handleRateLimitExceededException(e, exchange));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import org.springframework.security.core.Authentication;
|
|||
import org.springframework.security.core.AuthenticationException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorUtils;
|
||||
|
||||
@Slf4j
|
||||
public class UsernamePasswordDelegatingAuthenticationManager
|
||||
|
@ -38,6 +40,17 @@ public class UsernamePasswordDelegatingAuthenticationManager
|
|||
)
|
||||
.switchIfEmpty(
|
||||
Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication))
|
||||
);
|
||||
)
|
||||
// check if MFA is enabled after authenticated
|
||||
.map(a -> {
|
||||
if (a.getPrincipal() instanceof HaloUser user) {
|
||||
var twoFactorAuthSettings =
|
||||
TwoFactorUtils.getTwoFactorAuthSettings(user.getDelegate());
|
||||
if (twoFactorAuthSettings.isAvailable()) {
|
||||
a = new TwoFactorAuthentication(a);
|
||||
}
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static run.halo.app.infra.exception.Exceptions.createErrorResponse;
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.CredentialsContainer;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler,
|
||||
ServerAuthenticationFailureHandler {
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final ServerAuthenticationFailureHandler defaultFailureHandler =
|
||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||
|
||||
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
|
||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||
|
||||
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource) {
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(
|
||||
defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||
Authentication authentication) {
|
||||
if (authentication instanceof TwoFactorAuthentication) {
|
||||
// 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 xhrMatcher.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
|
||||
authentication)
|
||||
.then(Mono.empty())))
|
||||
.flatMap(isXhr -> {
|
||||
if (authentication instanceof CredentialsContainer container) {
|
||||
container.eraseCredentials();
|
||||
}
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(authentication.getPrincipal())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> handleAuthenticationException(Throwable exception,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> writeErrorResponse(ErrorResponse errorResponse,
|
||||
ServerWebExchange exchange) {
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Void> 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<Void> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
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 DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler {
|
||||
|
||||
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 DefaultTwoFactorAuthResponseHandler(MessageSource messageSource,
|
||||
ServerResponse.Context context) {
|
||||
this.messageSource = messageSource;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> 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 TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)),
|
||||
null, exchange, messageSource);
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,283 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
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;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
|
||||
|
||||
@Component
|
||||
public class TwoFactorAuthEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private final Validator validator;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
private final ExternalUrlSupplier externalUrl;
|
||||
|
||||
public TwoFactorAuthEndpoint(ReactiveExtensionClient client,
|
||||
UserService userService,
|
||||
TotpAuthService totpAuthService,
|
||||
Validator validator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
ExternalUrlSupplier externalUrl) {
|
||||
this.client = client;
|
||||
this.userService = userService;
|
||||
this.totpAuthService = totpAuthService;
|
||||
this.validator = validator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.externalUrl = externalUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> 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<ServerResponse> 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<ServerResponse> disableTwoFactor(ServerRequest request) {
|
||||
return toggleTwoFactor(request, false);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> enableTwoFactor(ServerRequest request) {
|
||||
return toggleTwoFactor(request, true);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> 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<ServerResponse> 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 = totpAuthService.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<ServerResponse> 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 = totpAuthService.validateTotp(rawSecret, code);
|
||||
if (!validated) {
|
||||
throw new ServerWebInputException("Invalid secret or code");
|
||||
}
|
||||
var encryptedSecret = totpAuthService.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<ServerResponse> getTwoFactorSettings(ServerRequest request) {
|
||||
return getCurrentUser()
|
||||
.map(TwoFactorUtils::getTwoFactorAuthSettings)
|
||||
.flatMap(settings -> ServerResponse.ok().bodyValue(settings));
|
||||
}
|
||||
|
||||
private Mono<User> 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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
public class TwoFactorAuthRequiredException extends ResponseStatusException {
|
||||
|
||||
private static final URI type = URI.create("https://halo.run/probs/2fa-required");
|
||||
|
||||
public TwoFactorAuthRequiredException(URI redirectURI) {
|
||||
super(HttpStatus.UNAUTHORIZED, "Two-factor authentication required");
|
||||
setType(type);
|
||||
getBody().setProperty("redirectURI", redirectURI);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface TwoFactorAuthResponseHandler {
|
||||
|
||||
Mono<Void> handle(ServerWebExchange exchange);
|
||||
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
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 org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter;
|
||||
|
||||
@Component
|
||||
public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
|
||||
|
||||
private final ServerSecurityContextRepository securityContextRepository;
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public TwoFactorAuthSecurityConfigurer(
|
||||
ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService, ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
this.totpAuthService = totpAuthService;
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
|
||||
context, messageSource);
|
||||
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
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 TwoFactorAuthentication extends AbstractAuthenticationToken {
|
||||
|
||||
private final Authentication previous;
|
||||
|
||||
/**
|
||||
* Creates a token with the supplied array of authorities.
|
||||
*
|
||||
* @param previous the previous authentication
|
||||
*/
|
||||
public TwoFactorAuthentication(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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.ReactiveAuthorizationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class TwoFactorAuthorizationManager
|
||||
implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
|
||||
private final ReactiveAuthorizationManager<AuthorizationContext> delegate;
|
||||
|
||||
private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp");
|
||||
|
||||
public TwoFactorAuthorizationManager(
|
||||
ReactiveAuthorizationManager<AuthorizationContext> delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
|
||||
AuthorizationContext context) {
|
||||
return authentication.flatMap(a -> {
|
||||
Mono<AuthorizationDecision> checked = delegate.check(Mono.just(a), context);
|
||||
if (a instanceof TwoFactorAuthentication) {
|
||||
checked = checked.filter(AuthorizationDecision::isGranted)
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION)));
|
||||
}
|
||||
return checked;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
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 DefaultTotpAuthService implements TotpAuthService {
|
||||
|
||||
private final BytesEncryptor encryptor;
|
||||
|
||||
public DefaultTotpAuthService(HaloProperties haloProperties) {
|
||||
// init secret key
|
||||
var keysRoot = haloProperties.getWorkDir().resolve("keys");
|
||||
this.encryptor = loadOrCreateEncryptor(keysRoot);
|
||||
}
|
||||
|
||||
private BytesEncryptor loadOrCreateEncryptor(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)));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
public interface TotpAuthService {
|
||||
|
||||
boolean validateTotp(String rawSecret, int code);
|
||||
|
||||
String generateTotpSecret();
|
||||
|
||||
String encryptSecret(String rawSecret);
|
||||
|
||||
String decryptSecret(String encryptedSecret);
|
||||
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
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.context.MessageSource;
|
||||
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.ServerAuthenticationConverter;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.login.HaloUser;
|
||||
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
public class TotpAuthenticationFilter extends AuthenticationWebFilter {
|
||||
|
||||
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService,
|
||||
ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
super(new TwoFactorAuthManager(totpAuthService));
|
||||
|
||||
setSecurityContextRepository(securityContextRepository);
|
||||
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
|
||||
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
|
||||
|
||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
||||
setAuthenticationSuccessHandler(handler);
|
||||
setAuthenticationFailureHandler(handler);
|
||||
}
|
||||
|
||||
private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter {
|
||||
|
||||
private final String codeParameter = "code";
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||
// Check the request is authenticated before.
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.filter(TwoFactorAuthentication.class::isInstance)
|
||||
.switchIfEmpty(Mono.error(
|
||||
() -> new TwoFactorAuthException("MFA Authentication required.")))
|
||||
.flatMap(authentication -> exchange.getFormData())
|
||||
.handle((formData, sink) -> {
|
||||
var codeStr = formData.getFirst(codeParameter);
|
||||
if (StringUtils.isBlank(codeStr)) {
|
||||
sink.error(new TwoFactorAuthException("Empty code parameter."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var code = Integer.parseInt(codeStr);
|
||||
sink.next(new TotpAuthenticationToken(code));
|
||||
} catch (NumberFormatException e) {
|
||||
sink.error(
|
||||
new TwoFactorAuthException("Invalid code parameter " + codeStr + '.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static class TwoFactorAuthException extends AuthenticationException {
|
||||
|
||||
public TwoFactorAuthException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
|
||||
public TwoFactorAuthException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class TwoFactorAuthManager implements ReactiveAuthenticationManager {
|
||||
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private TwoFactorAuthManager(TotpAuthService totpAuthService) {
|
||||
this.totpAuthService = totpAuthService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> 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(TwoFactorAuthentication.class)
|
||||
.map(TwoFactorAuthentication::getPrevious)
|
||||
.<Authentication>handle((prevAuth, sink) -> {
|
||||
var principal = prevAuth.getPrincipal();
|
||||
if (!(principal instanceof HaloUser haloUser)) {
|
||||
sink.error(new TwoFactorAuthException("Invalid MFA authentication."));
|
||||
return;
|
||||
}
|
||||
var encryptedSecret =
|
||||
haloUser.getDelegate().getSpec().getTotpEncryptedSecret();
|
||||
if (StringUtils.isBlank(encryptedSecret)) {
|
||||
sink.error(new TwoFactorAuthException("Empty secret configured"));
|
||||
return;
|
||||
}
|
||||
var rawSecret = totpAuthService.decryptSecret(encryptedSecret);
|
||||
var validated = totpAuthService.validateTotp(rawSecret, code);
|
||||
if (!validated) {
|
||||
sink.error(new TwoFactorAuthException("Invalid TOTP code " + code));
|
||||
return;
|
||||
}
|
||||
sink.next(prevAuth);
|
||||
})
|
||||
.doOnNext(previousAuth -> {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("TOTP authentication for {} with code {} successfully.",
|
||||
previousAuth.getName(), code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||
*/
|
||||
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
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 @@ import reactor.core.publisher.Mono;
|
|||
*/
|
||||
public interface AuthorizationRuleResolver {
|
||||
|
||||
Mono<AuthorizingVisitor> visitRules(UserDetails user, RequestInfo requestInfo);
|
||||
Mono<AuthorizingVisitor> visitRules(Authentication authentication, RequestInfo requestInfo);
|
||||
}
|
||||
|
|
|
@ -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 @@ import run.halo.app.infra.AnonymousUserConst;
|
|||
@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<AuthorizingVisitor> 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<AuthorizingVisitor> 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 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
}
|
||||
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 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
})
|
||||
.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 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
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<String> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,17 +6,13 @@ import org.springframework.http.server.reactive.ServerHttpRequest;
|
|||
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<AuthorizationContext> {
|
||||
implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
|
||||
private final AuthorizationRuleResolver ruleResolver;
|
||||
|
||||
|
@ -26,41 +22,24 @@ public class RequestInfoAuthorizationManager
|
|||
|
||||
@Override
|
||||
public Mono<AuthorizationDecision> check(Mono<Authentication> 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<Throwable> 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -532,28 +532,7 @@ class UserEndpointTest {
|
|||
.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);
|
||||
|
|
|
@ -5,16 +5,15 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
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 @@ class DefaultUserDetailServiceTest {
|
|||
.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 @@ class DefaultUserDetailServiceTest {
|
|||
.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 @@ class DefaultUserDetailServiceTest {
|
|||
.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();
|
||||
}
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -12,12 +18,16 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginAuthenticationConverterTest {
|
||||
|
@ -25,18 +35,51 @@ class LoginAuthenticationConverterTest {
|
|||
@Mock
|
||||
ServerWebExchange exchange;
|
||||
|
||||
MultiValueMap<String, String> formData;
|
||||
@Mock
|
||||
CryptoService cryptoService;
|
||||
|
||||
@Mock
|
||||
RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
@InjectMocks
|
||||
LoginAuthenticationConverter converter;
|
||||
|
||||
@Mock
|
||||
CryptoService cryptoService;
|
||||
MultiValueMap<String, String> formData;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
formData = new LinkedMultiValueMap<>();
|
||||
lenient().when(exchange.getFormData()).thenReturn(Mono.just(formData));
|
||||
var request = mock(ServerHttpRequest.class);
|
||||
var headers = new HttpHeaders();
|
||||
|
||||
when(request.getHeaders()).thenReturn(headers);
|
||||
when(exchange.getRequest()).thenReturn(request);
|
||||
when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown",
|
||||
"authentication"))
|
||||
.thenReturn(RateLimiter.ofDefaults("authentication"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTriggerRateLimit() {
|
||||
var username = "username";
|
||||
var password = "password";
|
||||
|
||||
formData.add("username", username);
|
||||
formData.add("password", Base64.getEncoder().encodeToString(password.getBytes()));
|
||||
var rateLimiter = RateLimiter.of("authentication", RateLimiterConfig.custom()
|
||||
.limitForPeriod(1)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ofMillis(0))
|
||||
.build());
|
||||
assertTrue(rateLimiter.acquirePermission(1));
|
||||
when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication"))
|
||||
.thenReturn(rateLimiter);
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.expectError(RateLimitExceededException.class)
|
||||
.verify();
|
||||
|
||||
verify(cryptoService, never()).decrypt(password.getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -51,10 +94,7 @@ class LoginAuthenticationConverterTest {
|
|||
when(cryptoService.decrypt(password.getBytes()))
|
||||
.thenReturn(Mono.just(decryptedPassword.getBytes()));
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.assertNext(token -> {
|
||||
assertEquals(username, token.getPrincipal());
|
||||
assertEquals(decryptedPassword, token.getCredentials());
|
||||
})
|
||||
.expectNext(new UsernamePasswordAuthenticationToken(username, decryptedPassword))
|
||||
.verifyComplete();
|
||||
|
||||
verify(cryptoService).decrypt(password.getBytes());
|
||||
|
@ -83,7 +123,7 @@ class LoginAuthenticationConverterTest {
|
|||
when(cryptoService.decrypt(password.getBytes()))
|
||||
.thenReturn(Mono.error(() -> new InvalidEncryptedMessageException("invalid message")));
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.verifyError(BadCredentialsException.class);
|
||||
.verifyError(BadCredentialsException.class);
|
||||
verify(cryptoService).decrypt(password.getBytes());
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String> 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();
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import static org.mockito.Mockito.times;
|
|||
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 @@ class DefaultRuleResolverTest {
|
|||
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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
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<TwoFactorAuthSettings>
|
||||
> {
|
||||
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<TwoFactorAuthSettings>
|
||||
> {
|
||||
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<TwoFactorAuthSettings>
|
||||
> {
|
||||
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<TwoFactorAuthSettings>
|
||||
> {
|
||||
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<TotpAuthLinkResponse>
|
||||
> {
|
||||
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<TwoFactorAuthSettings>
|
||||
> {
|
||||
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<TwoFactorAuthSettings> {
|
||||
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<TwoFactorAuthSettings> {
|
||||
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<TwoFactorAuthSettings> {
|
||||
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<TwoFactorAuthSettings> {
|
||||
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<TotpAuthLinkResponse> {
|
||||
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<TwoFactorAuthSettings> {
|
||||
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));
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -78,6 +78,12 @@ export interface UserSpec {
|
|||
* @memberof UserSpec
|
||||
*/
|
||||
registeredAt?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UserSpec
|
||||
*/
|
||||
totpEncryptedSecret?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
|
|
|
@ -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==}
|
||||
|
|
|
@ -11,6 +11,8 @@ 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 MfaForm from "./MfaForm.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
@ -68,6 +70,7 @@ const handleLogin = async () => {
|
|||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -92,7 +95,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) {
|
||||
mfaRequired.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorTitle || errorDetail) {
|
||||
Toast.error(errorDetail || errorTitle);
|
||||
|
@ -117,51 +129,57 @@ onMounted(() => {
|
|||
const inputClasses = {
|
||||
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||
};
|
||||
|
||||
// mfa
|
||||
const mfaRequired = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
id="login-form"
|
||||
v-model="loginForm"
|
||||
name="login-form"
|
||||
:actions="false"
|
||||
type="form"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="handleLogin"
|
||||
@keyup.enter="submitForm('login-form')"
|
||||
>
|
||||
<template v-if="!mfaRequired">
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="username"
|
||||
:placeholder="$t('core.login.fields.username.placeholder')"
|
||||
:validation-label="$t('core.login.fields.username.placeholder')"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
validation="required"
|
||||
id="login-form"
|
||||
v-model="loginForm"
|
||||
name="login-form"
|
||||
:actions="false"
|
||||
type="form"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="handleLogin"
|
||||
@keyup.enter="submitForm('login-form')"
|
||||
>
|
||||
<FormKit
|
||||
:classes="inputClasses"
|
||||
name="username"
|
||||
:placeholder="$t('core.login.fields.username.placeholder')"
|
||||
:validation-label="$t('core.login.fields.username.placeholder')"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
validation="required"
|
||||
>
|
||||
</FormKit>
|
||||
<FormKit
|
||||
id="passwordInput"
|
||||
:classes="inputClasses"
|
||||
name="password"
|
||||
:placeholder="$t('core.login.fields.password.placeholder')"
|
||||
:validation-label="$t('core.login.fields.password.placeholder')"
|
||||
type="password"
|
||||
validation="required"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</FormKit>
|
||||
</FormKit>
|
||||
<FormKit
|
||||
id="passwordInput"
|
||||
:classes="inputClasses"
|
||||
name="password"
|
||||
:placeholder="$t('core.login.fields.password.placeholder')"
|
||||
:validation-label="$t('core.login.fields.password.placeholder')"
|
||||
type="password"
|
||||
validation="required"
|
||||
autocomplete="current-password"
|
||||
<VButton
|
||||
class="mt-8"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="submitForm('login-form')"
|
||||
>
|
||||
</FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
class="mt-8"
|
||||
block
|
||||
:loading="loading"
|
||||
type="secondary"
|
||||
@click="submitForm('login-form')"
|
||||
>
|
||||
{{ $t(buttonText) }}
|
||||
</VButton>
|
||||
{{ $t(buttonText) }}
|
||||
</VButton>
|
||||
</template>
|
||||
<MfaForm v-else @succeed="$emit('succeed')" />
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts" setup>
|
||||
import { submitForm } from "@formkit/core";
|
||||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import qs from "qs";
|
||||
import axios from "axios";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "succeed"): void;
|
||||
}>();
|
||||
|
||||
async function onSubmit({ code }: { code: string }) {
|
||||
try {
|
||||
const _csrf = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("XSRF-TOKEN"))
|
||||
?.split("=")[1];
|
||||
|
||||
if (!_csrf) {
|
||||
Toast.warning("CSRF token not found");
|
||||
return;
|
||||
}
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_URL}/login/2fa/totp`,
|
||||
qs.stringify({
|
||||
code,
|
||||
_csrf,
|
||||
}),
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
emit("succeed");
|
||||
} catch (error) {
|
||||
Toast.error("验证失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
id="mfa-form"
|
||||
name="mfa-form"
|
||||
type="form"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
@submit="onSubmit"
|
||||
@keyup.enter="submitForm('mfa-form')"
|
||||
>
|
||||
<FormKit
|
||||
:classes="{
|
||||
outer: '!py-0',
|
||||
}"
|
||||
name="code"
|
||||
placeholder="请输入两步验证码"
|
||||
validation-label="两步验证码"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
validation="required"
|
||||
>
|
||||
</FormKit>
|
||||
</FormKit>
|
||||
<VButton class="mt-8" block type="secondary" @click="submitForm('mfa-form')">
|
||||
验证
|
||||
</VButton>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export const ERROR_MFA_REQUIRED_TYPE = "https://halo.run/probs/2fa-required";
|
|
@ -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(
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts" setup>
|
||||
import TotpConfigureModal from "./components/TotpConfigureModal.vue";
|
||||
import TotpDeletionModal from "./components/TotpDeletionModal.vue";
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
VButton,
|
||||
VEntity,
|
||||
VEntityField,
|
||||
VLoading,
|
||||
VSpace,
|
||||
} from "@halo-dev/components";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
|
||||
import RiShieldKeyholeLine from "~icons/ri/shield-keyhole-line";
|
||||
import TwoFactorEnableModal from "./components/TwoFactorEnableModal.vue";
|
||||
import TwoFactorDisableModal from "./components/TwoFactorDisableModal.vue";
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["two-factor-settings"],
|
||||
queryFn: async () => {
|
||||
const { data } =
|
||||
await apiClient.twoFactor.getTwoFactorAuthenticationSettings();
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const twoFactorEnableModalVisible = ref(false);
|
||||
const twoFactorDisableModalVisible = ref(false);
|
||||
|
||||
function onEnabledChange(payload: Event) {
|
||||
const target = payload.target as HTMLInputElement;
|
||||
// Do not change the checked state of the checkbox
|
||||
target.checked = !target.checked;
|
||||
if (settings.value?.enabled) {
|
||||
twoFactorDisableModalVisible.value = true;
|
||||
} else {
|
||||
twoFactorEnableModalVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const totpConfigureModalVisible = ref(false);
|
||||
const totpDeletionModalVisible = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-5">
|
||||
<label class="flex cursor-pointer items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="settings?.enabled"
|
||||
@change="onEnabledChange"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">启用两步验证</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="isLoading" />
|
||||
|
||||
<Transition v-else appear name="fade">
|
||||
<ul
|
||||
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base border"
|
||||
role="list"
|
||||
>
|
||||
<li class="bg-gray-50 px-4 py-3">
|
||||
<span class="text-sm font-semibold text-gray-900">验证方式</span>
|
||||
</li>
|
||||
<li>
|
||||
<VEntity>
|
||||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<RiShieldKeyholeLine />
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
title="TOTP"
|
||||
description="使用 TOTP 应用程序配置两步验证"
|
||||
/>
|
||||
</template>
|
||||
<template #end>
|
||||
<StatusDotField
|
||||
:state="settings?.totpConfigured ? 'success' : 'default'"
|
||||
:text="settings?.totpConfigured ? '已配置' : '未配置'"
|
||||
></StatusDotField>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<VSpace>
|
||||
<VButton size="sm" @click="totpConfigureModalVisible = true">
|
||||
{{ settings?.totpConfigured ? "重新配置" : "配置" }}
|
||||
</VButton>
|
||||
<VButton
|
||||
v-if="settings?.totpConfigured"
|
||||
size="sm"
|
||||
type="danger"
|
||||
@click="totpDeletionModalVisible = true"
|
||||
>
|
||||
停用
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
</VEntity>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
|
||||
<TotpConfigureModal
|
||||
v-if="totpConfigureModalVisible"
|
||||
@close="totpConfigureModalVisible = false"
|
||||
/>
|
||||
|
||||
<TotpDeletionModal
|
||||
v-if="totpDeletionModalVisible"
|
||||
@close="totpDeletionModalVisible = false"
|
||||
/>
|
||||
|
||||
<TwoFactorEnableModal
|
||||
v-if="twoFactorEnableModalVisible"
|
||||
@close="twoFactorEnableModalVisible = false"
|
||||
/>
|
||||
|
||||
<TwoFactorDisableModal
|
||||
v-if="twoFactorDisableModalVisible"
|
||||
@close="twoFactorDisableModalVisible = false"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts" setup>
|
||||
const emit = defineEmits<{
|
||||
(event: "submit", password: string): void;
|
||||
}>();
|
||||
|
||||
function onSubmit({ password }: { password: string }) {
|
||||
emit("submit", password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
id="password-validation-form"
|
||||
type="form"
|
||||
name="password-validation-form"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<FormKit
|
||||
type="password"
|
||||
label="验证密码"
|
||||
validation="required"
|
||||
name="password"
|
||||
help="当前账号的登录密码"
|
||||
autocomplete="current-password"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
</template>
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { useQRCode } from "@vueuse/integrations/useQRCode";
|
||||
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||
import { computed } from "vue";
|
||||
import type { TotpRequest } from "@halo-dev/api-client";
|
||||
import { ref } from "vue";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["totp-auth-link"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.twoFactor.getTotpAuthLink();
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const qrcode = useQRCode(computed(() => data.value?.authLink || ""));
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationKey: ["configure-totp"],
|
||||
mutationFn: async ({ totpRequest }: { totpRequest: TotpRequest }) => {
|
||||
await apiClient.twoFactor.configurerTotp({
|
||||
totpRequest: totpRequest,
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
Toast.success("配置成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["two-factor-settings"] });
|
||||
modal.value.close();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: TotpRequest) {
|
||||
mutate({ totpRequest: data });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:width="500"
|
||||
:centered="false"
|
||||
title="TOTP 配置"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-4 space-y-3 border-b border-gray-100 pb-4 text-gray-900">
|
||||
<div class="text-sm font-semibold">使用验证器应用扫描下方二维码:</div>
|
||||
<img :src="qrcode" class="rounded-base border border-gray-100" />
|
||||
<details>
|
||||
<summary class="cursor-pointer select-none text-sm text-gray-800">
|
||||
如果无法扫描二维码,点击查看代替步骤
|
||||
</summary>
|
||||
<div class="mt-3 rounded-base border border-gray-100 p-2">
|
||||
<span class="text-sm text-gray-600">
|
||||
使用以下代码手动配置验证器应用:
|
||||
</span>
|
||||
<div class="mt-2">
|
||||
<code
|
||||
class="select-all rounded bg-gray-200 p-1 text-xs text-gray-900"
|
||||
>
|
||||
{{ data?.rawSecret }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<FormKit id="totp-form" type="form" name="totp-form" @submit="onSubmit">
|
||||
<FormKit
|
||||
type="number"
|
||||
name="code"
|
||||
label="验证码"
|
||||
validation="required"
|
||||
help="从验证器应用获得的 6 位验证码"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
type="password"
|
||||
label="验证密码"
|
||||
validation="required"
|
||||
name="password"
|
||||
help="当前账号的登录密码"
|
||||
autocomplete="current-password"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
:model-value="data?.rawSecret"
|
||||
type="hidden"
|
||||
name="secret"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
:loading="isLoading"
|
||||
type="secondary"
|
||||
@click="$formkit.submit('totp-form')"
|
||||
>
|
||||
完成
|
||||
</VButton>
|
||||
<VButton @click="modal.close()">关闭</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||
import { useMutation, useQueryClient } from "@tanstack/vue-query";
|
||||
import { ref } from "vue";
|
||||
import PasswordValidationForm from "./PasswordValidationForm.vue";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationKey: ["totp-deletion"],
|
||||
mutationFn: async ({ password }: { password: string }) => {
|
||||
return await apiClient.twoFactor.deleteTotp({
|
||||
passwordRequest: {
|
||||
password: password,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
Toast.success("停用成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["two-factor-settings"] });
|
||||
modal.value.close();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(password: string) {
|
||||
mutate({ password });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:width="500"
|
||||
:centered="false"
|
||||
title="停用 TOTP"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<PasswordValidationForm @submit="onSubmit" />
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
:loading="isLoading"
|
||||
type="danger"
|
||||
@click="$formkit.submit('password-validation-form')"
|
||||
>
|
||||
停用
|
||||
</VButton>
|
||||
<VButton @click="modal.close()">关闭</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||
import { useMutation, useQueryClient } from "@tanstack/vue-query";
|
||||
import { ref } from "vue";
|
||||
import PasswordValidationForm from "./PasswordValidationForm.vue";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationKey: ["disable-two-factor"],
|
||||
mutationFn: async ({ password }: { password: string }) => {
|
||||
return await apiClient.twoFactor.disableTwoFactor({
|
||||
passwordRequest: {
|
||||
password: password,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
Toast.success("停用成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["two-factor-settings"] });
|
||||
modal.value.close();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(password: string) {
|
||||
mutate({ password });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:width="500"
|
||||
:centered="false"
|
||||
title="停用两步验证"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<PasswordValidationForm @submit="onSubmit" />
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
:loading="isLoading"
|
||||
type="danger"
|
||||
@click="$formkit.submit('password-validation-form')"
|
||||
>
|
||||
停用
|
||||
</VButton>
|
||||
<VButton @click="modal.close()">关闭</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||
import { useMutation, useQueryClient } from "@tanstack/vue-query";
|
||||
import { ref } from "vue";
|
||||
import PasswordValidationForm from "./PasswordValidationForm.vue";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationKey: ["enable-two-factor"],
|
||||
mutationFn: async ({ password }: { password: string }) => {
|
||||
return await apiClient.twoFactor.enableTwoFactor({
|
||||
passwordRequest: {
|
||||
password: password,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
Toast.success("启用成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["two-factor-settings"] });
|
||||
modal.value.close();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(password: string) {
|
||||
mutate({ password });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:width="500"
|
||||
:centered="false"
|
||||
title="启用两步验证"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<PasswordValidationForm @submit="onSubmit" />
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
:loading="isLoading"
|
||||
type="secondary"
|
||||
@click="$formkit.submit('password-validation-form')"
|
||||
>
|
||||
启用
|
||||
</VButton>
|
||||
<VButton @click="modal.close()">关闭</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -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'
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue