diff --git a/api/build.gradle b/api/build.gradle index c04f50aadb..690e28db07 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -63,6 +63,8 @@ dependencies { api "io.github.resilience4j:resilience4j-spring-boot3" api "io.github.resilience4j:resilience4j-reactor" + api "com.j256.two-factor-auth:two-factor-auth" + runtimeOnly 'io.r2dbc:r2dbc-h2' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:r2dbc-postgresql' diff --git a/api/src/main/java/run/halo/app/core/extension/User.java b/api/src/main/java/run/halo/app/core/extension/User.java index 60e443b6aa..a3c89c9ccb 100644 --- a/api/src/main/java/run/halo/app/core/extension/User.java +++ b/api/src/main/java/run/halo/app/core/extension/User.java @@ -45,9 +45,9 @@ public class User extends AbstractExtension { public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user"; @Schema(requiredMode = REQUIRED) - private UserSpec spec; + private UserSpec spec = new UserSpec(); - private UserStatus status; + private UserStatus status = new UserStatus(); @Data public static class UserSpec { @@ -72,6 +72,8 @@ public static class UserSpec { private Boolean twoFactorAuthEnabled; + private String totpEncryptedSecret; + private Boolean disabled; private Integer loginHistoryLimit; diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 9b150b0746..6d1a5d4ce4 100644 --- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -31,7 +31,6 @@ import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import run.halo.app.security.DefaultServerAuthenticationEntryPoint; import run.halo.app.security.DefaultUserDetailService; import run.halo.app.security.DynamicMatcherSecurityWebFilterChain; import run.halo.app.security.authentication.SecurityConfigurer; @@ -42,6 +41,7 @@ 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 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, 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 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, var authManagerResolver = builder().add( new PatServerWebExchangeMatcher(), new PatAuthenticationManager(client, patJwkSupplier)) - // TODO Add other authentication mangers here. e.g.: JwtAuthentiationManager. + // TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager. .build(); oauth2.authenticationManagerResolver(authManagerResolver); }) - .exceptionHandling( - spec -> spec.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint())); + ; // Integrate with other configurers separately securityConfigurers.orderedStream() diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index 50564c6a0f..2650839deb 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -82,11 +82,13 @@ 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 @@ record ChangePasswordRequest( @NonNull Mono 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) diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java deleted file mode 100644 index 9152f77319..0000000000 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java +++ /dev/null @@ -1,46 +0,0 @@ -package run.halo.app.core.extension.service; - -import java.util.Collection; -import java.util.Set; -import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; -import run.halo.app.security.authorization.AuthorityUtils; - -/** - *

Obtain the authorities from the authenticated authentication and construct it as a RoleBinding - * list.

- *

After JWT authentication, the roles stored in the authorities are the roles owned by the user, - * so there is no need to query from the database.

- *

For tokens in other formats, after authentication, fill the authorities with the token into - * the SecurityContextHolder.

- * - * @author guqing - * @see AnonymousAuthenticationFilter - * @since 2.0.0 - */ -@Slf4j -public class DefaultRoleBindingService implements RoleBindingService { - private static final String SCOPE_AUTHORITY_PREFIX = AuthorityUtils.SCOPE_PREFIX; - private static final String ROLE_AUTHORITY_PREFIX = AuthorityUtils.ROLE_PREFIX; - - @Override - public Set listBoundRoleNames(Collection authorities) { - return authorities.stream() - .map(GrantedAuthority::getAuthority) - // Exclude anonymous user roles - .filter(authority -> !authority.equals("ROLE_ANONYMOUS")) - .map(scope -> { - if (scope.startsWith(SCOPE_AUTHORITY_PREFIX)) { - scope = scope.replaceFirst(SCOPE_AUTHORITY_PREFIX, ""); - // keep checking the ROLE_ here - } - if (scope.startsWith(ROLE_AUTHORITY_PREFIX)) { - return scope.replaceFirst(ROLE_AUTHORITY_PREFIX, ""); - } - return scope; - }) - .collect(Collectors.toSet()); - } -} diff --git a/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java b/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java deleted file mode 100644 index 9b19082ba4..0000000000 --- a/application/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java +++ /dev/null @@ -1,16 +0,0 @@ -package run.halo.app.core.extension.service; - -import java.util.Collection; -import java.util.Set; -import org.springframework.security.core.GrantedAuthority; - -/** - * @author guqing - * @since 2.0.0 - */ -@FunctionalInterface -public interface RoleBindingService { - - Set listBoundRoleNames(Collection authorities); - -} diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java new file mode 100644 index 0000000000..4ba0069542 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java @@ -0,0 +1,63 @@ +package run.halo.app.infra.exception; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; +import org.springframework.validation.BindingResult; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.util.BindErrorUtils; + +public class RequestBodyValidationException extends ServerWebInputException { + + private final BindingResult bindingResult; + + public RequestBodyValidationException(BindingResult bindingResult) { + super("Validation failure", null, null, null, null); + this.bindingResult = bindingResult; + } + + @Override + public ProblemDetail updateAndGetBody(MessageSource messageSource, Locale locale) { + var detail = super.updateAndGetBody(messageSource, locale); + detail.setProperty("errors", collectAllErrors(messageSource, locale)); + return detail; + } + + private List collectAllErrors(MessageSource messageSource, Locale locale) { + var globalErrors = resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale); + var fieldErrors = resolveErrors(bindingResult.getFieldErrors(), messageSource, locale); + var errors = new ArrayList(globalErrors.size() + fieldErrors.size()); + errors.addAll(globalErrors); + errors.addAll(fieldErrors); + return errors; + } + + @Override + public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) { + return new Object[] { + resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale), + resolveErrors(bindingResult.getFieldErrors(), messageSource, locale) + }; + } + + @Override + public Object[] getDetailMessageArguments() { + return new Object[] { + resolveErrors(bindingResult.getGlobalErrors(), null, Locale.getDefault()), + resolveErrors(bindingResult.getFieldErrors(), null, Locale.getDefault()) + }; + } + + private static List resolveErrors( + List errors, + @Nullable MessageSource messageSource, + Locale locale) { + return messageSource == null + ? BindErrorUtils.resolve(errors).values().stream().toList() + : BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList(); + } +} diff --git a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java index 95bf446c56..528f5253ba 100644 --- a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java +++ b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java @@ -2,8 +2,12 @@ import static run.halo.app.core.extension.User.GROUP; import static run.halo.app.core.extension.User.KIND; +import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; +import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME; +import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.User; @@ -16,6 +20,7 @@ import run.halo.app.core.extension.service.UserService; import run.halo.app.extension.GroupKind; import run.halo.app.infra.exception.UserNotFoundException; +import run.halo.app.security.authentication.login.HaloUser; public class DefaultUserDetailService implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService { @@ -43,15 +48,19 @@ public Mono findByUsername(String username) { .flatMap(user -> { var name = user.getMetadata().getName(); var subject = new Subject(KIND, name, GROUP); - return roleService.listRoleRefs(subject) + + var builder = new HaloUser.Builder(user); + + var setAuthorities = roleService.listRoleRefs(subject) .filter(this::isRoleRef) .map(RoleRef::getName) + // every authenticated user should have authenticated and anonymous roles. + .concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME) + .map(roleName -> new SimpleGrantedAuthority(ROLE_PREFIX + roleName)) .collectList() - .map(roleNames -> User.builder() - .username(name) - .password(user.getSpec().getPassword()) - .roles(roleNames.toArray(new String[0])) - .build()); + .doOnNext(builder::authorities); + + return setAuthorities.then(Mono.fromSupplier(builder::build)); }); } diff --git a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java new file mode 100644 index 0000000000..bdb5979e75 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java @@ -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); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java new file mode 100644 index 0000000000..efcb482d52 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java @@ -0,0 +1,52 @@ +package run.halo.app.security; + +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING; +import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; +import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; +import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.SecurityConfigurer; + +@Component +public class LogoutSecurityConfigurer implements SecurityConfigurer { + + @Override + public void configure(ServerHttpSecurity http) { + http.logout(logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler())); + http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING); + } + + public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler { + + private final ServerLogoutSuccessHandler defaultHandler; + + public LogoutSuccessHandler() { + var defaultHandler = new RedirectServerLogoutSuccessHandler(); + defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout")); + this.defaultHandler = defaultHandler; + } + + @Override + public Mono onLogoutSuccess(WebFilterExchange exchange, + Authentication authentication) { + return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange()) + .flatMap(matchResult -> { + if (matchResult.isMatch()) { + var response = exchange.getExchange().getResponse(); + response.setStatusCode(HttpStatus.NO_CONTENT); + return response.setComplete(); + } + return defaultHandler.onLogoutSuccess(exchange, authentication); + }); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java index 7a32808456..e4807b2c9e 100644 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java @@ -1,5 +1,9 @@ package run.halo.app.security.authentication.jwt; +import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; +import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME; +import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX; + import java.util.ArrayList; import java.util.Collection; import org.springframework.core.convert.converter.Converter; @@ -7,9 +11,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; -import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; -import run.halo.app.security.authorization.AuthorityUtils; /** * GrantedAuthorities converter for SCOPE_ and ROLE_ prefixes. @@ -17,7 +19,7 @@ * @author johnniang */ public class JwtScopesAndRolesGrantedAuthoritiesConverter - implements Converter> { + implements Converter> { private final Converter> delegate; @@ -28,17 +30,23 @@ public JwtScopesAndRolesGrantedAuthoritiesConverter() { @Override public Flux convert(Jwt jwt) { var grantedAuthorities = new ArrayList(); + + // add default roles + grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + ANONYMOUS_ROLE_NAME)); + grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + AUTHENTICATED_ROLE_NAME)); + var delegateAuthorities = delegate.convert(jwt); if (delegateAuthorities != null) { grantedAuthorities.addAll(delegateAuthorities); } var roles = jwt.getClaimAsStringList("roles"); - if (!CollectionUtils.isEmpty(roles)) { + if (roles != null) { roles.stream() - .map(role -> AuthorityUtils.ROLE_PREFIX + role) + .map(role -> ROLE_PREFIX + role) .map(SimpleGrantedAuthority::new) .forEach(grantedAuthorities::add); } + return Flux.fromIterable(grantedAuthorities); } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java b/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java deleted file mode 100644 index dcd3db1e68..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/login/DelegatingLogoutPageGeneratingWebFilter.java +++ /dev/null @@ -1,33 +0,0 @@ -package run.halo.app.security.authentication.login; - -import org.springframework.lang.NonNull; -import org.springframework.security.config.web.server.SecurityWebFiltersOrder; -import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; -import run.halo.app.security.AdditionalWebFilter; - -/** - * Generates a default log out page. - * - * @author guqing - * @since 2.4.0 - */ -@Component -public class DelegatingLogoutPageGeneratingWebFilter implements AdditionalWebFilter { - private final LogoutPageGeneratingWebFilter logoutPageGeneratingWebFilter = - new LogoutPageGeneratingWebFilter(); - - @Override - @NonNull - public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { - return logoutPageGeneratingWebFilter.filter(exchange, chain); - } - - @Override - public int getOrder() { - return SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING.getOrder(); - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java new file mode 100644 index 0000000000..6104f14a84 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java @@ -0,0 +1,113 @@ +package run.halo.app.security.authentication.login; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.Assert; +import run.halo.app.core.extension.User; + +public class HaloUser implements UserDetails, CredentialsContainer { + + private final User delegate; + + private final Collection authorities; + + public HaloUser(User delegate, Collection 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 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 authorities; + + public Builder(User user) { + this.user = user; + } + + public Builder authorities(Collection authorities) { + this.authorities = authorities; + return this; + } + + public HaloUser build() { + return new HaloUser(user, authorities); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java index 9e2d825ca9..45267236e6 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java @@ -2,27 +2,38 @@ 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 convert(ServerWebExchange exchange) { return super.convert(exchange) // validate the password - .flatMap(token -> { + .flatMap(token -> { var credentials = (String) token.getCredentials(); byte[] credentialsBytes; try { @@ -37,6 +48,23 @@ public Mono convert(ServerWebExchange exchange) { .map(decryptedCredentials -> new UsernamePasswordAuthenticationToken( token.getPrincipal(), new String(decryptedCredentials, UTF_8))); - }); + }) + .transformDeferred(createIpBasedRateLimiter(exchange)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + } + + private RateLimiterOperator 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); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java index 26479199ec..3cfbc51e57 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java @@ -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.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 @@ @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 UsernamePasswordAuthenticator(ServerResponse.Context context, 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 int getOrder() { 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 @@ ReactiveAuthenticationManager defaultAuthenticationManager() { return manager; } - private RateLimiterOperator 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 handleRateLimitExceededException(RateLimitExceededException e, - ServerWebExchange exchange) { - var errorResponse = createErrorResponse(e, null, exchange, messageSource); - return writeErrorResponse(errorResponse, exchange); - } - - private Mono handleAuthenticationException(AuthenticationException exception, - ServerWebExchange exchange) { - var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource); - return writeErrorResponse(errorResponse, exchange); - } - - private Mono 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 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 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 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)); - } - - } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java index fe1b253169..4a2ba58d56 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java @@ -6,6 +6,8 @@ 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 Mono authenticate(Authentication authentication) { ) .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; + }); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java new file mode 100644 index 0000000000..1b695438e8 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -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 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 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 handleAuthenticationException(Throwable exception, + ServerWebExchange exchange) { + var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource); + return writeErrorResponse(errorResponse, exchange); + } + + private Mono writeErrorResponse(ErrorResponse errorResponse, + ServerWebExchange exchange) { + return ServerResponse.status(errorResponse.getStatusCode()) + .contentType(APPLICATION_JSON) + .bodyValue(errorResponse.getBody()) + .flatMap(response -> response.writeTo(exchange, context)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java deleted file mode 100644 index 7259569b61..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordLogoutHandler.java +++ /dev/null @@ -1,95 +0,0 @@ -package run.halo.app.security.authentication.login; - -import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; - -import java.net.URI; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.lang.NonNull; -import org.springframework.security.config.web.server.SecurityWebFiltersOrder; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.server.WebFilterExchange; -import org.springframework.security.web.server.authentication.logout.LogoutWebFilter; -import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; -import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; -import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; -import run.halo.app.security.AdditionalWebFilter; - -/** - * Logout handler for username password authentication. - * - * @author guqing - * @since 2.4.0 - */ -@Component -public class UsernamePasswordLogoutHandler implements AdditionalWebFilter { - private final ServerSecurityContextRepository securityContextRepository; - private final LogoutWebFilter logoutWebFilter; - - /** - * Constructs a {@link UsernamePasswordLogoutHandler} with the given - * {@link ServerSecurityContextRepository}. - * It will create a {@link LogoutWebFilter} instance and configure it. - * - * @param securityContextRepository a {@link ServerSecurityContextRepository} instance - */ - public UsernamePasswordLogoutHandler( - ServerSecurityContextRepository securityContextRepository) { - this.securityContextRepository = securityContextRepository; - - this.logoutWebFilter = new LogoutWebFilter(); - configureLogoutWebFilter(logoutWebFilter); - } - - @Override - @NonNull - public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { - return logoutWebFilter.filter(exchange, chain); - } - - @Override - public int getOrder() { - return SecurityWebFiltersOrder.LOGOUT.getOrder(); - } - - void configureLogoutWebFilter(LogoutWebFilter filter) { - var securityContextServerLogoutHandler = new SecurityContextServerLogoutHandler(); - securityContextServerLogoutHandler.setSecurityContextRepository(securityContextRepository); - filter.setLogoutHandler(securityContextServerLogoutHandler); - filter.setLogoutSuccessHandler(new LogoutSuccessHandler()); - } - - /** - * Success handler for logout. - * - * @author johnniang - */ - public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler { - - private final ServerLogoutSuccessHandler defaultHandler; - - public LogoutSuccessHandler() { - var defaultHandler = new RedirectServerLogoutSuccessHandler(); - defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout")); - this.defaultHandler = defaultHandler; - } - - @Override - public Mono onLogoutSuccess(WebFilterExchange exchange, - Authentication authentication) { - return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange()) - .flatMap(matchResult -> { - if (matchResult.isMatch()) { - exchange.getExchange().getResponse().setStatusCode(HttpStatus.OK); - return Mono.empty(); - } - return defaultHandler.onLogoutSuccess(exchange, authentication); - }); - } - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java new file mode 100644 index 0000000000..9de2be00fe --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java @@ -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 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)); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java new file mode 100644 index 0000000000..dc07dbf62d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java @@ -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 endpoint() { + var tag = groupVersion() + "/Authentication/TwoFactor"; + return route().nest(path("/authentications/two-factor"), + () -> route() + .GET("/settings", this::getTwoFactorSettings, + builder -> builder.operationId("GetTwoFactorAuthenticationSettings") + .tag(tag) + .description("Get Two-factor authentication settings.") + .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) + .PUT("/settings/enabled", this::enableTwoFactor, + builder -> builder.operationId("EnableTwoFactor") + .tag(tag) + .description("Enable Two-factor authentication") + .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) + .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) + .PUT("/settings/disabled", this::disableTwoFactor, + builder -> builder.operationId("DisableTwoFactor") + .tag(tag) + .description("Disable Two-factor authentication") + .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) + .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) + .POST("/totp", this::configureTotp, + builder -> builder.operationId("ConfigurerTotp") + .tag(tag) + .description("Configure a TOTP") + .requestBody(requestBodyBuilder().implementation(TotpRequest.class)) + .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) + .DELETE("/totp/-", this::deleteTotp, + builder -> builder.operationId("DeleteTotp") + .tag(tag) + .requestBody(requestBodyBuilder().implementation(PasswordRequest.class)) + .response(responseBuilder().implementation(TwoFactorAuthSettings.class))) + .GET("/totp/auth-link", this::getTotpAuthLink, + builder -> builder.operationId("GetTotpAuthLink") + .tag(tag) + .description("Get TOTP auth link, including secret") + .response(responseBuilder().implementation(TotpAuthLinkResponse.class))) + .build(), + builder -> builder.description("Two-factor authentication endpoint(User-scoped)") + ).build(); + } + + private Mono deleteTotp(ServerRequest request) { + var totpDeleteRequestMono = request.bodyToMono(PasswordRequest.class) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) + .doOnNext( + passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest")); + + var twoFactorAuthSettings = + totpDeleteRequestMono.flatMap(passwordRequest -> getCurrentUser() + .filter(user -> { + var rawPassword = passwordRequest.getPassword(); + var encodedPassword = user.getSpec().getPassword(); + return this.passwordEncoder.matches(rawPassword, encodedPassword); + }) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Invalid password"))) + .doOnNext(user -> { + var spec = user.getSpec(); + spec.setTotpEncryptedSecret(null); + }) + .flatMap(client::update) + .map(TwoFactorUtils::getTwoFactorAuthSettings)); + return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class); + } + + @Data + public static class PasswordRequest { + + @NotBlank + private String password; + + } + + private Mono disableTwoFactor(ServerRequest request) { + return toggleTwoFactor(request, false); + } + + private Mono enableTwoFactor(ServerRequest request) { + return toggleTwoFactor(request, true); + } + + private Mono toggleTwoFactor(ServerRequest request, boolean enabled) { + return request.bodyToMono(PasswordRequest.class) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) + .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest")) + .flatMap(passwordRequest -> getCurrentUser() + .filter(user -> { + var encodedPassword = user.getSpec().getPassword(); + var rawPassword = passwordRequest.getPassword(); + return passwordEncoder.matches(rawPassword, encodedPassword); + }) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Invalid password"))) + .doOnNext(user -> user.getSpec().setTwoFactorAuthEnabled(enabled)) + .flatMap(client::update) + .map(TwoFactorUtils::getTwoFactorAuthSettings)) + .flatMap(twoFactorAuthSettings -> ServerResponse.ok().bodyValue(twoFactorAuthSettings)); + } + + private Mono getTotpAuthLink(ServerRequest request) { + var authLinkResponse = getCurrentUser() + .map(user -> { + var username = user.getMetadata().getName(); + var url = externalUrl.getURL(request.exchange().getRequest()); + var authority = url.getAuthority(); + var authKeyId = username + ":" + authority; + var rawSecret = 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 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 getTwoFactorSettings(ServerRequest request) { + return getCurrentUser() + .map(TwoFactorUtils::getTwoFactorAuthSettings) + .flatMap(settings -> ServerResponse.ok().bodyValue(settings)); + } + + private Mono getCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(TwoFactorAuthEndpoint::isAuthenticatedUser) + .switchIfEmpty(Mono.error(AccessDeniedException::new)) + .map(Authentication::getName) + .flatMap(userService::getUser); + } + + private static boolean isAuthenticatedUser(Authentication authentication) { + return authentication != null && !(authentication instanceof AnonymousAuthenticationToken); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.security.halo.run/v1alpha1"); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java new file mode 100644 index 0000000000..bc686b79cc --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthRequiredException.java @@ -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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java new file mode 100644 index 0000000000..a4216a4831 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java @@ -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 handle(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java new file mode 100644 index 0000000000..8dd1b5ac63 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java @@ -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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java new file mode 100644 index 0000000000..73a9981f5e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSettings.java @@ -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); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java new file mode 100644 index 0000000000..76a679e063 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java @@ -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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java new file mode 100644 index 0000000000..f716717a46 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java @@ -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 { + + private final ReactiveAuthorizationManager delegate; + + private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp"); + + public TwoFactorAuthorizationManager( + ReactiveAuthorizationManager delegate) { + this.delegate = delegate; + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + return authentication.flatMap(a -> { + Mono 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; + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java new file mode 100644 index 0000000000..8c5c7bcc7e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorUtils.java @@ -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; + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java new file mode 100644 index 0000000000..b722cf7e45 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/DefaultTotpAuthService.java @@ -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))); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java new file mode 100644 index 0000000000..380524311e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthService.java @@ -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); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java new file mode 100644 index 0000000000..0234cc17ab --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java @@ -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 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 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) + .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); + } + }); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java new file mode 100644 index 0000000000..af8df5a9f4 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationToken.java @@ -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; + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/Attributes.java b/application/src/main/java/run/halo/app/security/authorization/Attributes.java index c4a0758f7e..a68317ba76 100644 --- a/application/src/main/java/run/halo/app/security/authorization/Attributes.java +++ b/application/src/main/java/run/halo/app/security/authorization/Attributes.java @@ -1,6 +1,6 @@ package run.halo.app.security.authorization; -import org.springframework.security.core.userdetails.UserDetails; +import java.security.Principal; /** * Attributes is used by an Authorizer to get information about a request @@ -13,7 +13,7 @@ public interface Attributes { /** * @return the UserDetails object to authorize */ - UserDetails getUser(); + Principal getPrincipal(); /** * @return the verb associated with API requests(this includes get, list, diff --git a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java index 9ac9869063..3fb6833671 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java +++ b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java @@ -1,6 +1,6 @@ package run.halo.app.security.authorization; -import org.springframework.security.core.userdetails.UserDetails; +import java.security.Principal; /** * @author guqing @@ -8,16 +8,16 @@ */ public class AttributesRecord implements Attributes { private final RequestInfo requestInfo; - private final UserDetails user; + private final Principal principal; - public AttributesRecord(UserDetails user, RequestInfo requestInfo) { + public AttributesRecord(Principal principal, RequestInfo requestInfo) { this.requestInfo = requestInfo; - this.user = user; + this.principal = principal; } @Override - public UserDetails getUser() { - return this.user; + public Principal getPrincipal() { + return this.principal; } @Override diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java index b00fa310d0..fce28a6778 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java @@ -20,6 +20,10 @@ public enum AuthorityUtils { public static final String SUPER_ROLE_NAME = "super-role"; + public static final String AUTHENTICATED_ROLE_NAME = "authenticated"; + + public static final String ANONYMOUS_ROLE_NAME = "anonymous"; + /** * Converts an array of GrantedAuthority objects to a role set. * diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java index 57ebf34424..cec9a5e038 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizationRuleResolver.java @@ -1,6 +1,6 @@ package run.halo.app.security.authorization; -import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.Authentication; import reactor.core.publisher.Mono; /** @@ -9,5 +9,5 @@ */ public interface AuthorizationRuleResolver { - Mono visitRules(UserDetails user, RequestInfo requestInfo); + Mono visitRules(Authentication authentication, RequestInfo requestInfo); } diff --git a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java index e40f2554bd..0ff51ae863 100644 --- a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java +++ b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -1,18 +1,17 @@ package run.halo.app.security.authorization; -import java.util.HashSet; +import java.util.Collection; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.util.Assert; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.DefaultRoleBindingService; -import run.halo.app.core.extension.service.RoleBindingService; import run.halo.app.core.extension.service.RoleService; -import run.halo.app.infra.AnonymousUserConst; /** * @author guqing @@ -21,32 +20,24 @@ @Data @Slf4j public class DefaultRuleResolver implements AuthorizationRuleResolver { - private static final String AUTHENTICATED_ROLE = "authenticated"; - private RoleService roleService; - private RoleBindingService roleBindingService; + private RoleService roleService; public DefaultRuleResolver(RoleService roleService) { this.roleService = roleService; - this.roleBindingService = new DefaultRoleBindingService(); } @Override - public Mono visitRules(UserDetails user, RequestInfo requestInfo) { - var roleNamesImmutable = roleBindingService.listBoundRoleNames(user.getAuthorities()); - var roleNames = new HashSet<>(roleNamesImmutable); - if (!AnonymousUserConst.PRINCIPAL.equals(user.getUsername())) { - roleNames.add(AUTHENTICATED_ROLE); - roleNames.add(AnonymousUserConst.Role); - } - - var record = new AttributesRecord(user, requestInfo); + public Mono visitRules(Authentication authentication, + RequestInfo requestInfo) { + var roleNames = listBoundRoleNames(authentication.getAuthorities()); + var record = new AttributesRecord(authentication, requestInfo); var visitor = new AuthorizingVisitor(record); // If the request is an userspace scoped request, // then we should check whether the user is the owner of the userspace. if (StringUtils.isNotBlank(requestInfo.getUserspace())) { - if (!user.getUsername().equals(requestInfo.getUserspace())) { + if (!authentication.getName().equals(requestInfo.getUserspace())) { return Mono.fromSupplier(() -> { visitor.visit(null, null, null); return visitor; @@ -63,7 +54,7 @@ var record = new AttributesRecord(user, requestInfo); } String roleName = role.getMetadata().getName(); var rules = role.getRules(); - var source = roleBindingDescriber(roleName, user.getUsername()); + var source = roleBindingDescriber(roleName, authentication.getName()); for (var rule : rules) { if (!visitor.visit(source, rule, null)) { stopVisiting.set(true); @@ -73,7 +64,7 @@ var record = new AttributesRecord(user, requestInfo); }) .takeUntil(item -> stopVisiting.get()) .onErrorResume(t -> visitor.visit(null, null, t), t -> { - log.warn("Error occurred when visiting rules", t); + log.error("Error occurred when visiting rules", t); //Do nothing here return Mono.empty(); }) @@ -84,8 +75,15 @@ String roleBindingDescriber(String roleName, String subject) { return String.format("Binding role [%s] to [%s]", roleName, subject); } - public void setRoleBindingService(RoleBindingService roleBindingService) { - Assert.notNull(roleBindingService, "The roleBindingLister must not be null."); - this.roleBindingService = roleBindingService; + private static Set listBoundRoleNames( + Collection authorities) { + return authorities.stream() + .map(GrantedAuthority::getAuthority) + .map(authority -> { + authority = StringUtils.removeStart(authority, AuthorityUtils.SCOPE_PREFIX); + authority = StringUtils.removeStart(authority, AuthorityUtils.ROLE_PREFIX); + return authority; + }) + .collect(Collectors.toSet()); } } diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java index 850ccc7516..f450af7c47 100644 --- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java @@ -6,17 +6,13 @@ import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.server.authorization.AuthorizationContext; -import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; import run.halo.app.core.extension.service.RoleService; @Slf4j public class RequestInfoAuthorizationManager - implements ReactiveAuthorizationManager { + implements ReactiveAuthorizationManager { private final AuthorizationRuleResolver ruleResolver; @@ -26,41 +22,24 @@ public RequestInfoAuthorizationManager(RoleService roleService) { @Override public Mono check(Mono authentication, - AuthorizationContext context) { + AuthorizationContext context) { ServerHttpRequest request = context.getExchange().getRequest(); RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); - return authentication.flatMap(auth -> { - var userDetails = this.createUserDetails(auth); - return this.ruleResolver.visitRules(userDetails, requestInfo) - .map(visitor -> { - if (!visitor.isAllowed()) { - showErrorMessage(visitor.getErrors()); - return new AuthorizationDecision(false); - } - return new AuthorizationDecision(isGranted(auth)); - }); - }); + return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo) + .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) + .filter(AuthorizingVisitor::isAllowed) + .map(visitor -> new AuthorizationDecision(isGranted(auth))) + .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false)))); } private boolean isGranted(Authentication authentication) { return authentication != null && authentication.isAuthenticated(); } - private UserDetails createUserDetails(Authentication authentication) { - Assert.notNull(authentication, "The authentication must not be null."); - return User.withUsername(authentication.getName()) - .authorities(authentication.getAuthorities()) - .password("") - .build(); - } - private void showErrorMessage(List errors) { - if (CollectionUtils.isEmpty(errors)) { - return; - } - for (Throwable error : errors) { - log.error("Access decision error: ", error); + if (errors != null) { + errors.forEach(error -> log.error("Access decision error", error)); } } diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml index fb3856d392..03055e067d 100644 --- a/application/src/main/resources/extensions/role-template-authenticated.yaml +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -14,6 +14,7 @@ metadata: "role-template-stats", "role-template-annotation-setting", "role-template-manage-own-pat", + "role-template-manage-own-authentications", "role-template-user-notification" ] rules: @@ -115,6 +116,19 @@ rules: - apiGroups: [ "api.security.halo.run" ] resources: [ "personalaccesstokens/actions" ] verbs: [ "update" ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-manage-own-authentications + labels: + halo.run/role-template: "true" + halo.run/hidden: "true" +rules: + - apiGroups: [ "api.security.halo.run" ] + resources: [ "authentications", "authentications/totp" ] + verbs: [ "*" ] --- apiVersion: v1alpha1 kind: Role diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 179aba87eb..af2dc8c7b3 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -532,28 +532,7 @@ void shouldUploadSuccessfully() { .exchange() .expectStatus() .isOk() - .expectBody() - .json(""" - { - "spec":{ - "displayName":"Faker", - "avatar":"fake-avatar.png", - "email":"hi@halo.run", - "password":"fake-password", - "bio":"Fake bio" - }, - "status":null, - "apiVersion":"v1alpha1", - "kind":"User", - "metadata":{ - "name":"fake-user", - "annotations":{ - "halo.run/avatar-attachment-name": - "fake-attachment" - } - } - } - """); + .expectBody(User.class).isEqualTo(currentUser); verify(client).get(User.class, "fake-user"); verify(client).update(currentUser); diff --git a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java index a9a5d28143..975a71dbbe 100644 --- a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java +++ b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -5,16 +5,15 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.security.core.authority.AuthorityUtils.authorityListToSet; -import java.util.List; -import java.util.stream.Collectors; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import reactor.core.publisher.Flux; @@ -101,11 +100,9 @@ void shouldFindUserDetailsByExistingUsername() { .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); - assertEquals(List.of("ROLE_fake-role"), - gotUser.getAuthorities() - .stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toList())); + assertEquals( + Set.of("ROLE_fake-role", "ROLE_authenticated", "ROLE_anonymous"), + authorityListToSet(gotUser.getAuthorities())); }) .verifyComplete(); } @@ -133,7 +130,11 @@ void shouldFindUserDetailsByExistingUsernameButKindOfRoleRefIsNotRole() { .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); - assertEquals(0, gotUser.getAuthorities().size()); + assertEquals(2, gotUser.getAuthorities().size()); + assertEquals( + Set.of("ROLE_anonymous", "ROLE_authenticated"), + authorityListToSet(gotUser.getAuthorities()) + ); }) .verifyComplete(); } @@ -155,7 +156,9 @@ void shouldFindUserDetailsByExistingUsernameButWithoutAnyRoles() { .assertNext(gotUser -> { assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); - assertEquals(0, gotUser.getAuthorities().size()); + assertEquals( + Set.of("ROLE_anonymous", "ROLE_authenticated"), + authorityListToSet(gotUser.getAuthorities())); }) .verifyComplete(); } diff --git a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java index e55e2b01f4..358a208185 100644 --- a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java +++ b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java @@ -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.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 formData; + @Mock + CryptoService cryptoService; + + @Mock + RateLimiterRegistry rateLimiterRegistry; @InjectMocks LoginAuthenticationConverter converter; - @Mock - CryptoService cryptoService; + MultiValueMap 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 @@ void applyUsernameAndPasswordThenCreatesTokenSuccess() { 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 @@ void applyUsernameAndInvalidPasswordThenBadCredentialsException() { 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()); } diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java deleted file mode 100644 index 890fcf5202..0000000000 --- a/application/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package run.halo.app.security.authorization; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import run.halo.app.core.extension.service.DefaultRoleBindingService; - -/** - * Tests for {@link DefaultRoleBindingService}. - * - * @author guqing - * @since 2.0.0 - */ -public class DefaultRoleBindingServiceTest { - - private DefaultRoleBindingService roleBindingLister; - - @BeforeEach - void setUp() { - roleBindingLister = new DefaultRoleBindingService(); - } - - @AfterEach - void cleanUp() { - SecurityContextHolder.clearContext(); - } - - @Test - void listWhenAuthorizedRoles() { - var authorities = List.of( - new SimpleGrantedAuthority("readFake"), - new SimpleGrantedAuthority("fake.read"), - new SimpleGrantedAuthority("ROLE_role.fake.read"), - new SimpleGrantedAuthority("SCOPE_scope.fake.read"), - new SimpleGrantedAuthority("SCOPE_ROLE_scope.role.fake.read")); - - Set roleBindings = roleBindingLister.listBoundRoleNames(authorities); - assertThat(roleBindings).isNotNull(); - assertThat(roleBindings).isEqualTo(Set.of( - "readFake", - "fake.read", - "role.fake.read", - "scope.fake.read", - "scope.role.fake.read")); - } - - @Test - void listWhenUnauthorizedThenEmpty() { - var roleBindings = roleBindingLister.listBoundRoleNames(Collections.emptyList()); - assertThat(roleBindings).isEmpty(); - } -} diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java index 7785c91fdd..a311bac5de 100644 --- a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method; +import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; import java.util.List; @@ -34,29 +35,33 @@ class DefaultRuleResolverTest { @Test void visitRules() { - when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost"))) + when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var authentication = authenticated(fakeUser, fakeUser.getPassword(), + fakeUser.getAuthorities()); + var cases = getRequestResolveCases(); cases.forEach(requestResolveCase -> { var httpMethod = HttpMethod.valueOf(requestResolveCase.method); var request = method(httpMethod, requestResolveCase.url).build(); var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); - StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo)) + StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo)) .assertNext( visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed())) .verifyComplete(); }); - verify(roleService, times(cases.size())).listDependenciesFlux( - Set.of("authenticated", "anonymous", "ruleReadPost")); + verify(roleService, times(cases.size())).listDependenciesFlux(Set.of("ruleReadPost")); } @Test void visitRulesForUserspaceScope() { - when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost"))) + when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var authentication = + authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); var cases = List.of( new RequestResolveCase("/api/v1/categories", "POST", true), new RequestResolveCase("/api/v1/categories", "DELETE", true), @@ -71,7 +76,7 @@ void visitRulesForUserspaceScope() { var httpMethod = HttpMethod.valueOf(requestResolveCase.method); var request = method(httpMethod, requestResolveCase.url).build(); var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); - StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo)) + StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo)) .assertNext( visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed())) .verifyComplete(); diff --git a/console/package.json b/console/package.json index 77b70ba352..c96d3cfafa 100644 --- a/console/package.json +++ b/console/package.json @@ -71,6 +71,7 @@ "@uppy/xhr-upload": "^3.6.0", "@vueuse/components": "^10.3.0", "@vueuse/core": "^10.3.0", + "@vueuse/integrations": "^10.5.0", "@vueuse/router": "^10.3.0", "@vueuse/shared": "^10.3.0", "axios": "^0.27.2", @@ -89,6 +90,7 @@ "path-browserify": "^1.0.1", "pinia": "^2.1.6", "pretty-bytes": "^6.0.0", + "qrcode": "^1.5.3", "qs": "^6.11.1", "short-unique-id": "^5.0.2", "transliteration": "^2.3.5", diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES index b1676ea613..ced5501674 100644 --- a/console/packages/api-client/src/.openapi-generator/FILES +++ b/console/packages/api-client/src/.openapi-generator/FILES @@ -29,6 +29,7 @@ api/api-notification-halo-run-v1alpha1-notification-api.ts api/api-notification-halo-run-v1alpha1-notifier-api.ts api/api-notification-halo-run-v1alpha1-subscription-api.ts api/api-plugin-halo-run-v1alpha1-plugin-api.ts +api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts api/api-security-halo-run-v1alpha1-personal-access-token-api.ts api/auth-halo-run-v1alpha1-auth-provider-api.ts api/auth-halo-run-v1alpha1-user-connection-api.ts @@ -186,6 +187,7 @@ models/notifier-descriptor.ts models/notifier-info.ts models/notifier-setting-ref.ts models/owner-info.ts +models/password-request.ts models/password-reset-email-request.ts models/pat-spec.ts models/personal-access-token-list.ts @@ -283,6 +285,9 @@ models/theme-list.ts models/theme-spec.ts models/theme-status.ts models/theme.ts +models/totp-auth-link-response.ts +models/totp-request.ts +models/two-factor-auth-settings.ts models/upgrade-from-uri-request.ts models/user-connection-list.ts models/user-connection-spec.ts diff --git a/console/packages/api-client/src/api.ts b/console/packages/api-client/src/api.ts index fcdd3ce669..cd7dcb3335 100644 --- a/console/packages/api-client/src/api.ts +++ b/console/packages/api-client/src/api.ts @@ -40,6 +40,7 @@ export * from "./api/api-notification-halo-run-v1alpha1-notification-api"; export * from "./api/api-notification-halo-run-v1alpha1-notifier-api"; export * from "./api/api-notification-halo-run-v1alpha1-subscription-api"; export * from "./api/api-plugin-halo-run-v1alpha1-plugin-api"; +export * from "./api/api-security-halo-run-v1alpha1-authentication-two-factor-api"; export * from "./api/api-security-halo-run-v1alpha1-personal-access-token-api"; export * from "./api/auth-halo-run-v1alpha1-auth-provider-api"; export * from "./api/auth-halo-run-v1alpha1-user-connection-api"; diff --git a/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts b/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts new file mode 100644 index 0000000000..24cacfd949 --- /dev/null +++ b/console/packages/api-client/src/api/api-security-halo-run-v1alpha1-authentication-two-factor-api.ts @@ -0,0 +1,795 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from "../configuration"; +import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios"; +import globalAxios from "axios"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { PasswordRequest } from "../models"; +// @ts-ignore +import { TotpAuthLinkResponse } from "../models"; +// @ts-ignore +import { TotpRequest } from "../models"; +// @ts-ignore +import { TwoFactorAuthSettings } from "../models"; +/** + * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - axios parameter creator + * @export + */ +export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiAxiosParamCreator = + function (configuration?: Configuration) { + return { + /** + * Configure a TOTP + * @param {TotpRequest} [totpRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + configurerTotp: async ( + totpRequest?: TotpRequest, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + totpRequest, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteTotp: async ( + passwordRequest?: PasswordRequest, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp/-`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + passwordRequest, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Disable Two-factor authentication + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + disableTwoFactor: async ( + passwordRequest?: PasswordRequest, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings/disabled`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + passwordRequest, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Enable Two-factor authentication + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + enableTwoFactor: async ( + passwordRequest?: PasswordRequest, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings/enabled`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + passwordRequest, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get TOTP auth link, including secret + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTotpAuthLink: async ( + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/totp/auth-link`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get Two-factor authentication settings. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTwoFactorAuthenticationSettings: async ( + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/authentications/two-factor/settings`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; + }; + +/** + * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - functional programming interface + * @export + */ +export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp = function ( + configuration?: Configuration +) { + const localVarAxiosParamCreator = + ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiAxiosParamCreator( + configuration + ); + return { + /** + * Configure a TOTP + * @param {TotpRequest} [totpRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async configurerTotp( + totpRequest?: TotpRequest, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.configurerTotp( + totpRequest, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteTotp( + passwordRequest?: PasswordRequest, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteTotp( + passwordRequest, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Disable Two-factor authentication + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async disableTwoFactor( + passwordRequest?: PasswordRequest, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.disableTwoFactor( + passwordRequest, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Enable Two-factor authentication + * @param {PasswordRequest} [passwordRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async enableTwoFactor( + passwordRequest?: PasswordRequest, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.enableTwoFactor( + passwordRequest, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Get TOTP auth link, including secret + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getTotpAuthLink( + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTotpAuthLink( + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Get Two-factor authentication settings. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getTwoFactorAuthenticationSettings( + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getTwoFactorAuthenticationSettings( + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - factory interface + * @export + */ +export const ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFactory = + function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance + ) { + const localVarFp = + ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp(configuration); + return { + /** + * Configure a TOTP + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + configurerTotp( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .configurerTotp(requestParameters.totpRequest, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteTotp( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .deleteTotp(requestParameters.passwordRequest, options) + .then((request) => request(axios, basePath)); + }, + /** + * Disable Two-factor authentication + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + disableTwoFactor( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .disableTwoFactor(requestParameters.passwordRequest, options) + .then((request) => request(axios, basePath)); + }, + /** + * Enable Two-factor authentication + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + enableTwoFactor( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .enableTwoFactor(requestParameters.passwordRequest, options) + .then((request) => request(axios, basePath)); + }, + /** + * Get TOTP auth link, including secret + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTotpAuthLink( + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .getTotpAuthLink(options) + .then((request) => request(axios, basePath)); + }, + /** + * Get Two-factor authentication settings. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTwoFactorAuthenticationSettings( + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .getTwoFactorAuthenticationSettings(options) + .then((request) => request(axios, basePath)); + }, + }; + }; + +/** + * Request parameters for configurerTotp operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi. + * @export + * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest + */ +export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest { + /** + * + * @type {TotpRequest} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotp + */ + readonly totpRequest?: TotpRequest; +} + +/** + * Request parameters for deleteTotp operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi. + * @export + * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest + */ +export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest { + /** + * + * @type {PasswordRequest} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotp + */ + readonly passwordRequest?: PasswordRequest; +} + +/** + * Request parameters for disableTwoFactor operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi. + * @export + * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest + */ +export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest { + /** + * + * @type {PasswordRequest} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactor + */ + readonly passwordRequest?: PasswordRequest; +} + +/** + * Request parameters for enableTwoFactor operation in ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi. + * @export + * @interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest + */ +export interface ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest { + /** + * + * @type {PasswordRequest} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactor + */ + readonly passwordRequest?: PasswordRequest; +} + +/** + * ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi - object-oriented interface + * @export + * @class ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + * @extends {BaseAPI} + */ +export class ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi extends BaseAPI { + /** + * Configure a TOTP + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public configurerTotp( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiConfigurerTotpRequest = {}, + options?: AxiosRequestConfig + ) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .configurerTotp(requestParameters.totpRequest, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public deleteTotp( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDeleteTotpRequest = {}, + options?: AxiosRequestConfig + ) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .deleteTotp(requestParameters.passwordRequest, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Disable Two-factor authentication + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public disableTwoFactor( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiDisableTwoFactorRequest = {}, + options?: AxiosRequestConfig + ) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .disableTwoFactor(requestParameters.passwordRequest, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Enable Two-factor authentication + * @param {ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public enableTwoFactor( + requestParameters: ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiEnableTwoFactorRequest = {}, + options?: AxiosRequestConfig + ) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .enableTwoFactor(requestParameters.passwordRequest, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Get TOTP auth link, including secret + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public getTotpAuthLink(options?: AxiosRequestConfig) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .getTotpAuthLink(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Get Two-factor authentication settings. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi + */ + public getTwoFactorAuthenticationSettings(options?: AxiosRequestConfig) { + return ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApiFp( + this.configuration + ) + .getTwoFactorAuthenticationSettings(options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/console/packages/api-client/src/models/index.ts b/console/packages/api-client/src/models/index.ts index f1bc6a1300..d0c5700934 100644 --- a/console/packages/api-client/src/models/index.ts +++ b/console/packages/api-client/src/models/index.ts @@ -106,6 +106,7 @@ export * from "./notifier-descriptor-spec"; export * from "./notifier-info"; export * from "./notifier-setting-ref"; export * from "./owner-info"; +export * from "./password-request"; export * from "./password-reset-email-request"; export * from "./pat-spec"; export * from "./personal-access-token"; @@ -203,6 +204,9 @@ export * from "./theme"; export * from "./theme-list"; export * from "./theme-spec"; export * from "./theme-status"; +export * from "./totp-auth-link-response"; +export * from "./totp-request"; +export * from "./two-factor-auth-settings"; export * from "./upgrade-from-uri-request"; export * from "./user"; export * from "./user-connection"; diff --git a/console/packages/api-client/src/models/password-request.ts b/console/packages/api-client/src/models/password-request.ts new file mode 100644 index 0000000000..04247c71dc --- /dev/null +++ b/console/packages/api-client/src/models/password-request.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface PasswordRequest + */ +export interface PasswordRequest { + /** + * + * @type {string} + * @memberof PasswordRequest + */ + password: string; +} diff --git a/console/packages/api-client/src/models/totp-auth-link-response.ts b/console/packages/api-client/src/models/totp-auth-link-response.ts new file mode 100644 index 0000000000..094ec0c5e0 --- /dev/null +++ b/console/packages/api-client/src/models/totp-auth-link-response.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface TotpAuthLinkResponse + */ +export interface TotpAuthLinkResponse { + /** + * + * @type {string} + * @memberof TotpAuthLinkResponse + */ + authLink?: string; + /** + * + * @type {string} + * @memberof TotpAuthLinkResponse + */ + rawSecret?: string; +} diff --git a/console/packages/api-client/src/models/totp-request.ts b/console/packages/api-client/src/models/totp-request.ts new file mode 100644 index 0000000000..51cf3f6ac8 --- /dev/null +++ b/console/packages/api-client/src/models/totp-request.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface TotpRequest + */ +export interface TotpRequest { + /** + * + * @type {string} + * @memberof TotpRequest + */ + code: string; + /** + * + * @type {string} + * @memberof TotpRequest + */ + password: string; + /** + * + * @type {string} + * @memberof TotpRequest + */ + secret: string; +} diff --git a/console/packages/api-client/src/models/two-factor-auth-settings.ts b/console/packages/api-client/src/models/two-factor-auth-settings.ts new file mode 100644 index 0000000000..7c5269f55a --- /dev/null +++ b/console/packages/api-client/src/models/two-factor-auth-settings.ts @@ -0,0 +1,45 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface TwoFactorAuthSettings + */ +export interface TwoFactorAuthSettings { + /** + * + * @type {boolean} + * @memberof TwoFactorAuthSettings + */ + available?: boolean; + /** + * + * @type {boolean} + * @memberof TwoFactorAuthSettings + */ + emailVerified?: boolean; + /** + * + * @type {boolean} + * @memberof TwoFactorAuthSettings + */ + enabled?: boolean; + /** + * + * @type {boolean} + * @memberof TwoFactorAuthSettings + */ + totpConfigured?: boolean; +} diff --git a/console/packages/api-client/src/models/user-spec.ts b/console/packages/api-client/src/models/user-spec.ts index c2c2d1efb9..8a55b40b5d 100644 --- a/console/packages/api-client/src/models/user-spec.ts +++ b/console/packages/api-client/src/models/user-spec.ts @@ -78,6 +78,12 @@ export interface UserSpec { * @memberof UserSpec */ registeredAt?: string; + /** + * + * @type {string} + * @memberof UserSpec + */ + totpEncryptedSecret?: string; /** * * @type {boolean} diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index f5195a7618..9881ade70d 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@vueuse/core': specifier: ^10.3.0 version: 10.3.0(vue@3.3.4) + '@vueuse/integrations': + specifier: ^10.5.0 + version: 10.7.1(axios@0.27.2)(fuse.js@6.6.2)(qrcode@1.5.3)(vue@3.3.4) '@vueuse/router': specifier: ^10.3.0 version: 10.3.0(vue-router@4.2.4)(vue@3.3.4) @@ -164,6 +167,9 @@ importers: pretty-bytes: specifier: ^6.0.0 version: 6.0.0 + qrcode: + specifier: ^1.5.3 + version: 1.5.3 qs: specifier: ^6.11.1 version: 6.11.1 @@ -6519,6 +6525,10 @@ packages: resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==} dev: false + /@types/web-bluetooth@0.0.20: + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + dev: false + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -7199,10 +7209,78 @@ packages: - vue dev: false + /@vueuse/core@10.7.1(vue@3.3.4): + resolution: {integrity: sha512-74mWHlaesJSWGp1ihg76vAnfVq9NTv1YT0SYhAQ6zwFNdBkkP+CKKJmVOEHcdSnLXCXYiL5e7MaewblfiYLP7g==} + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.7.1 + '@vueuse/shared': 10.7.1(vue@3.3.4) + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + + /@vueuse/integrations@10.7.1(axios@0.27.2)(fuse.js@6.6.2)(qrcode@1.5.3)(vue@3.3.4): + resolution: {integrity: sha512-cKo5LEeKVHdBRBtMTOrDPdR0YNtrmN9IBfdcnY2P3m5LHVrsD0xiHUtAH1WKjHQRIErZG6rJUa6GA4tWZt89Og==} + peerDependencies: + async-validator: '*' + axios: '*' + change-case: '*' + drauu: '*' + focus-trap: '*' + fuse.js: '*' + idb-keyval: '*' + jwt-decode: '*' + nprogress: '*' + qrcode: '*' + sortablejs: '*' + universal-cookie: '*' + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + dependencies: + '@vueuse/core': 10.7.1(vue@3.3.4) + '@vueuse/shared': 10.7.1(vue@3.3.4) + axios: 0.27.2 + fuse.js: 6.6.2 + qrcode: 1.5.3 + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + /@vueuse/metadata@10.3.0: resolution: {integrity: sha512-Ema3YhNOa4swDsV0V7CEY5JXvK19JI/o1szFO1iWxdFg3vhdFtCtSTP26PCvbUpnUtNHBY2wx5y3WDXND5Pvnw==} dev: false + /@vueuse/metadata@10.7.1: + resolution: {integrity: sha512-jX8MbX5UX067DYVsbtrmKn6eG6KMcXxLRLlurGkZku5ZYT3vxgBjui2zajvUZ18QLIjrgBkFRsu7CqTAg18QFw==} + dev: false + /@vueuse/router@10.3.0(vue-router@4.2.4)(vue@3.3.4): resolution: {integrity: sha512-WCx/BAxO0eInuOcyNRBxDLS16tnNqzdaR6/babg6AUgAIL0TCfmHBh46wJa6hhg+NMGjd6HzCaktxBasp+0c0A==} peerDependencies: @@ -7225,6 +7303,15 @@ packages: - vue dev: false + /@vueuse/shared@10.7.1(vue@3.3.4): + resolution: {integrity: sha512-v0jbRR31LSgRY/C5i5X279A/WQjD6/JsMzGa+eqt658oJ75IvQXAeONmwvEMrvJQKnRElq/frzBR7fhmWY5uLw==} + dependencies: + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + /@webassemblyjs/ast@1.11.6: resolution: {integrity: sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==} dependencies: @@ -8181,7 +8268,6 @@ packages: /camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - dev: true /camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} @@ -8400,7 +8486,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - dev: true /cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -8978,7 +9063,6 @@ packages: /decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - dev: true /decimal.js@10.4.2: resolution: {integrity: sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==} @@ -9192,6 +9276,10 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dev: false + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -9367,6 +9455,10 @@ packages: engines: {node: '>= 4'} dev: true + /encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + dev: false + /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -10608,7 +10700,6 @@ packages: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 - dev: true /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} @@ -12579,7 +12670,6 @@ packages: engines: {node: '>=8'} dependencies: p-locate: 4.1.0 - dev: true /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -13581,7 +13671,6 @@ packages: engines: {node: '>=6'} dependencies: p-try: 2.2.0 - dev: true /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -13609,7 +13698,6 @@ packages: engines: {node: '>=8'} dependencies: p-limit: 2.3.0 - dev: true /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} @@ -13655,7 +13743,6 @@ packages: /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - dev: true /pac-proxy-agent@7.0.1: resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==} @@ -13773,7 +13860,6 @@ packages: /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} @@ -13958,6 +14044,11 @@ packages: pathe: 1.1.1 dev: true + /pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + dev: false + /polished@4.2.2: resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==} engines: {node: '>=10'} @@ -14583,6 +14674,17 @@ packages: - utf-8-validate dev: true + /qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + dev: false + /qs@6.11.0: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} @@ -14996,7 +15098,6 @@ packages: /require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - dev: true /requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} @@ -15427,7 +15528,6 @@ packages: /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - dev: true /set-function-length@1.1.1: resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==} @@ -17288,6 +17388,21 @@ packages: vue: 3.3.4 dev: false + /vue-demi@0.14.6(vue@3.3.4): + resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + dependencies: + vue: 3.3.4 + dev: false + /vue-docgen-api@4.75.1(vue@3.3.4): resolution: {integrity: sha512-MECZ3uExz+ssmhD/2XrFoQQs93y17IVO1KDYTp8nr6i9GNrk67AAto6QAtilW1H/pTDPMkQxJ7w/25ZIqVtfAA==} peerDependencies: @@ -17612,7 +17727,6 @@ packages: /which-module@2.0.0: resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==} - dev: true /which-pm@2.0.0: resolution: {integrity: sha512-Lhs9Pmyph0p5n5Z3mVnN0yWcbQYUAD7rbQUiMsQxOJ3T57k7RFe35SUwWMf7dsbDZks1uOmw4AecB/JMDj3v/w==} @@ -17851,7 +17965,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -17946,7 +18059,6 @@ packages: /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - dev: true /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} @@ -17988,7 +18100,6 @@ packages: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - dev: true /yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} @@ -18014,7 +18125,6 @@ packages: which-module: 2.0.0 y18n: 4.0.3 yargs-parser: 18.1.3 - dev: true /yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} diff --git a/console/src/components/login/LoginForm.vue b/console/src/components/login/LoginForm.vue index 4435b45251..9a25b52a20 100644 --- a/console/src/components/login/LoginForm.vue +++ b/console/src/components/login/LoginForm.vue @@ -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); diff --git a/console/src/components/login/MfaForm.vue b/console/src/components/login/MfaForm.vue new file mode 100644 index 0000000000..9a82c95d2b --- /dev/null +++ b/console/src/components/login/MfaForm.vue @@ -0,0 +1,72 @@ + + + diff --git a/console/src/constants/error-types.ts b/console/src/constants/error-types.ts new file mode 100644 index 0000000000..447fe61bbc --- /dev/null +++ b/console/src/constants/error-types.ts @@ -0,0 +1 @@ +export const ERROR_MFA_REQUIRED_TYPE = "https://halo.run/probs/2fa-required"; diff --git a/console/src/utils/api-client.ts b/console/src/utils/api-client.ts index a0871441a7..217defaab5 100644 --- a/console/src/utils/api-client.ts +++ b/console/src/utils/api-client.ts @@ -45,6 +45,7 @@ import { NotificationHaloRunV1alpha1NotifierDescriptorApi, ApiSecurityHaloRunV1alpha1PersonalAccessTokenApi, SecurityHaloRunV1alpha1PersonalAccessTokenApi, + ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi, UcApiContentHaloRunV1alpha1AttachmentApi, UcApiContentHaloRunV1alpha1PostApi, UcApiContentHaloRunV1alpha1SnapshotApi, @@ -247,6 +248,11 @@ function setupApiClient(axios: AxiosInstance) { baseURL, axios ), + twoFactor: new ApiSecurityHaloRunV1alpha1AuthenticationTwoFactorApi( + undefined, + baseURL, + axios + ), uc: { post: new UcApiContentHaloRunV1alpha1PostApi(undefined, baseURL, axios), attachment: new UcApiContentHaloRunV1alpha1AttachmentApi( diff --git a/console/uc-src/modules/profile/Profile.vue b/console/uc-src/modules/profile/Profile.vue index 0ed9097b99..2eb11e5f27 100644 --- a/console/uc-src/modules/profile/Profile.vue +++ b/console/uc-src/modules/profile/Profile.vue @@ -22,6 +22,7 @@ import DetailTab from "./tabs/Detail.vue"; import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue"; import { useRouteQuery } from "@vueuse/router"; import NotificationPreferences from "./tabs/NotificationPreferences.vue"; +import TwoFactor from "./tabs/TwoFactor.vue"; const { t } = useI18n(); @@ -79,6 +80,12 @@ const tabs: UserTab[] = [ component: markRaw(PersonalAccessTokensTab), priority: 30, }, + { + id: "2fa", + label: "两步验证", + component: markRaw(TwoFactor), + priority: 40, + }, ]; const tabbarItems = computed(() => { diff --git a/console/uc-src/modules/profile/tabs/TwoFactor.vue b/console/uc-src/modules/profile/tabs/TwoFactor.vue new file mode 100644 index 0000000000..699dbcf270 --- /dev/null +++ b/console/uc-src/modules/profile/tabs/TwoFactor.vue @@ -0,0 +1,128 @@ + + + diff --git a/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue b/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue new file mode 100644 index 0000000000..115033063f --- /dev/null +++ b/console/uc-src/modules/profile/tabs/components/PasswordValidationForm.vue @@ -0,0 +1,27 @@ + + + diff --git a/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue b/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue new file mode 100644 index 0000000000..ca02cb544c --- /dev/null +++ b/console/uc-src/modules/profile/tabs/components/TotpConfigureModal.vue @@ -0,0 +1,113 @@ + + + diff --git a/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue b/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue new file mode 100644 index 0000000000..5c013e54fb --- /dev/null +++ b/console/uc-src/modules/profile/tabs/components/TotpDeletionModal.vue @@ -0,0 +1,59 @@ + + + diff --git a/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue b/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue new file mode 100644 index 0000000000..f6070aaf7c --- /dev/null +++ b/console/uc-src/modules/profile/tabs/components/TwoFactorDisableModal.vue @@ -0,0 +1,59 @@ + + + diff --git a/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue b/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue new file mode 100644 index 0000000000..6220409543 --- /dev/null +++ b/console/uc-src/modules/profile/tabs/components/TwoFactorEnableModal.vue @@ -0,0 +1,59 @@ + + + diff --git a/platform/application/build.gradle b/platform/application/build.gradle index 375415cc2f..7905d86503 100644 --- a/platform/application/build.gradle +++ b/platform/application/build.gradle @@ -21,6 +21,7 @@ ext { springDocOpenAPI = "2.3.0" lucene = "9.7.0" resilience4jVersion = "2.0.2" + twoFactorAuth = "1.3" } javaPlatform { @@ -52,6 +53,7 @@ dependencies { api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion" api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion" + api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth" runtime 'org.mariadb:r2dbc-mariadb:1.1.4' }