Support logging in with encrypted password (#3480)

#### What type of PR is this?

/kind feature
/kind api-change
/area core
/area console
#### What this PR does / why we need it:

This PR creates AuthenticationWebFilter by ourselves instead of using FormLoginSpec directly. Because we have no chance to customize `org.springframework.security.web.server.authentication.ServerAuthenticationConverter` currently.

Meanwhile, we provide CryptoService(RSA) to generate key pair, get public key and decrypt message encrypted by public key.

There is a new endpoint to get public key which is used by console:

```bash
❯ curl localhost:8090/login/public-key -s | jq .
{
  "base64Format": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAouDtdDS751U8NcWfAAQ53ijEtkLnIHh1Thqkq5QHGslq2hBmWnNsIZFnc/bwVp00ImKLV2NtLgOuv5RRNS5iO+oqRvfOGdXLdW2nzqU2towtaMkYTEMJrsNMZp5BUNCGI7Z2xpPBZzvys0d1BvcpNFobX/LkOtcTyfB1DRp9ZAhxRYOZkTkCzaKo+6X11lnMexTsB3exzaXk9rRZ8XoJ+dWT5G0URs/PF2cDkgxuMdOFJzqDsb9HQYGI/etajdCcKs7mZsjmDgse9Cw9/3mgoTNnEGx9Wl89S0P+FJ7T5DALGt3/nSAlzmKdXJNBLf6Q44ogFpTWdq27JpJD3SKicQIDAQAB"
}
```

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/3419

#### Does this PR introduce a user-facing change?

```release-note
支持登录时密码加密传输
```
pull/3484/head^2
John Niang 2023-03-08 17:52:12 +08:00 committed by GitHub
parent df2300144e
commit d192b8c956
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 951 additions and 126 deletions

View File

@ -83,7 +83,8 @@
"vue-grid-layout": "3.0.0-beta1",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6",
"vuedraggable": "^4.1.0"
"vuedraggable": "^4.1.0",
"jsencrypt": "^3.3.2"
},
"devDependencies": {
"@changesets/cli": "^2.25.2",

View File

@ -21,6 +21,7 @@ api/content-halo-run-v1alpha1-reply-api.ts
api/content-halo-run-v1alpha1-single-page-api.ts
api/content-halo-run-v1alpha1-snapshot-api.ts
api/content-halo-run-v1alpha1-tag-api.ts
api/login-api.ts
api/metrics-halo-run-v1alpha1-counter-api.ts
api/plugin-halo-run-v1alpha1-plugin-api.ts
api/plugin-halo-run-v1alpha1-reverse-proxy-api.ts
@ -133,6 +134,7 @@ models/post-request.ts
models/post-spec.ts
models/post-status.ts
models/post.ts
models/public-key-response.ts
models/ref.ts
models/reply-list.ts
models/reply-request.ts

View File

@ -32,6 +32,7 @@ export * from "./api/content-halo-run-v1alpha1-reply-api";
export * from "./api/content-halo-run-v1alpha1-single-page-api";
export * from "./api/content-halo-run-v1alpha1-snapshot-api";
export * from "./api/content-halo-run-v1alpha1-tag-api";
export * from "./api/login-api";
export * from "./api/metrics-halo-run-v1alpha1-counter-api";
export * from "./api/plugin-halo-run-v1alpha1-plugin-api";
export * from "./api/plugin-halo-run-v1alpha1-reverse-proxy-api";

View File

@ -0,0 +1,176 @@
/* 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 { PublicKeyResponse } from "../models";
/**
* LoginApi - axios parameter creator
* @export
*/
export const LoginApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Read public key for encrypting password.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPublicKey: async (
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/login/public-key`;
// 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,
};
},
};
};
/**
* LoginApi - functional programming interface
* @export
*/
export const LoginApiFp = function (configuration?: Configuration) {
const localVarAxiosParamCreator = LoginApiAxiosParamCreator(configuration);
return {
/**
* Read public key for encrypting password.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPublicKey(
options?: AxiosRequestConfig
): Promise<
(
axios?: AxiosInstance,
basePath?: string
) => AxiosPromise<PublicKeyResponse>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPublicKey(
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* LoginApi - factory interface
* @export
*/
export const LoginApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = LoginApiFp(configuration);
return {
/**
* Read public key for encrypting password.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPublicKey(
options?: AxiosRequestConfig
): AxiosPromise<PublicKeyResponse> {
return localVarFp
.getPublicKey(options)
.then((request) => request(axios, basePath));
},
};
};
/**
* LoginApi - object-oriented interface
* @export
* @class LoginApi
* @extends {BaseAPI}
*/
export class LoginApi extends BaseAPI {
/**
* Read public key for encrypting password.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof LoginApi
*/
public getPublicKey(options?: AxiosRequestConfig) {
return LoginApiFp(this.configuration)
.getPublicKey(options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -18,7 +18,7 @@ import type { Configuration } from "./configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
export const BASE_PATH = "http://127.0.0.1:8090".replace(/\/+$/, "");
export const BASE_PATH = "http://localhost:8090".replace(/\/+$/, "");
/**
*

View File

@ -86,6 +86,7 @@ export * from "./post-list";
export * from "./post-request";
export * from "./post-spec";
export * from "./post-status";
export * from "./public-key-response";
export * from "./ref";
export * from "./reply";
export * from "./reply-list";

View File

@ -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 PublicKeyResponse
*/
export interface PublicKeyResponse {
/**
*
* @type {string}
* @memberof PublicKeyResponse
*/
base64Format?: string;
}

View File

@ -68,6 +68,7 @@ importers:
fuse.js: ^6.6.2
husky: ^8.0.2
jsdom: ^20.0.3
jsencrypt: ^3.3.2
lint-staged: ^13.1.0
lodash.clonedeep: ^4.5.0
lodash.debounce: ^4.0.8
@ -139,6 +140,7 @@ importers:
fastq: 1.15.0
floating-vue: 2.0.0-beta.20_vue@3.2.45
fuse.js: 6.6.2
jsencrypt: 3.3.2
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.isequal: 4.5.0
@ -4616,7 +4618,7 @@ packages:
/axios/0.26.1:
resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
dependencies:
follow-redirects: 1.15.2
follow-redirects: 1.15.2_debug@4.3.2
transitivePeerDependencies:
- debug
dev: true
@ -4624,7 +4626,7 @@ packages:
/axios/0.27.2:
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
dependencies:
follow-redirects: 1.15.2
follow-redirects: 1.15.2_debug@4.3.2
form-data: 4.0.0
transitivePeerDependencies:
- debug
@ -5431,7 +5433,6 @@ packages:
optional: true
dependencies:
ms: 2.1.2
dev: true
/debug/4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
@ -6669,15 +6670,6 @@ packages:
vue-resize: 2.0.0-alpha.1_vue@3.2.45
dev: false
/follow-redirects/1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
/follow-redirects/1.15.2_debug@4.3.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
@ -6688,7 +6680,6 @@ packages:
optional: true
dependencies:
debug: 4.3.2
dev: true
/foreground-child/2.0.0:
resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
@ -7671,6 +7662,10 @@ packages:
- utf-8-validate
dev: true
/jsencrypt/3.3.2:
resolution: {integrity: sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A==}
dev: false
/jsesc/0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
@ -8236,7 +8231,6 @@ packages:
/ms/2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}

View File

@ -8,6 +8,8 @@ import { Toast, VButton } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import qs from "qs";
import { submitForm } from "@formkit/core";
import { JSEncrypt } from "jsencrypt";
import { apiClient } from "@/utils/api-client";
const emit = defineEmits<{
(event: "succeed"): void;
@ -39,9 +41,17 @@ const handleLogin = async () => {
try {
loading.value = true;
const { data: publicKey } = await apiClient.login.getPublicKey();
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey.base64Format as string);
await axios.post(
`${import.meta.env.VITE_API_URL}/login`,
qs.stringify(loginForm.value),
qs.stringify({
...loginForm.value,
password: encrypt.encrypt(loginForm.value.password),
}),
{
withCredentials: true,
headers: {
@ -66,7 +76,7 @@ const handleLogin = async () => {
if (e.response?.status === 403) {
Toast.warning("CSRF Token 失效,请重新尝试", { duration: 5000 });
handleGenerateToken();
await handleGenerateToken();
return;
}

View File

@ -32,6 +32,7 @@ import {
V1alpha1SettingApi,
V1alpha1UserApi,
V1alpha1AnnotationSettingApi,
LoginApi,
} from "@halo-dev/api-client";
import type { AxiosError, AxiosInstance } from "axios";
import axios from "axios";
@ -184,6 +185,7 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
login: new LoginApi(undefined, baseURL, axios),
indices: new ApiConsoleHaloRunV1alpha1IndicesApi(undefined, baseURL, axios),
};
}

View File

@ -80,7 +80,7 @@ public class SwaggerConfig {
return GroupedOpenApi.builder()
.group("all-api")
.displayName("All APIs")
.pathsToMatch("/api/**", "/apis/**")
.pathsToMatch("/api/**", "/apis/**", "/login/**")
.build();
}

View File

@ -17,7 +17,6 @@ import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@ -25,6 +24,8 @@ import org.springframework.security.web.server.context.ServerSecurityContextRepo
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ReactiveExtensionClient;
@ -33,6 +34,10 @@ import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.security.DefaultUserDetailService;
import run.halo.app.security.SuperAdminInitializer;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.login.CryptoService;
import run.halo.app.security.authentication.login.PublicKeyRouteBuilder;
import run.halo.app.security.authentication.login.RsaKeyScheduledGenerator;
import run.halo.app.security.authentication.login.impl.RsaKeyService;
import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
/**
@ -52,7 +57,7 @@ public class WebServerSecurityConfig {
ServerSecurityContextRepository securityContextRepository) {
http.securityMatcher(
pathMatchers("/api/**", "/apis/**", "/login", "/logout", "/actuator/**"))
pathMatchers("/api/**", "/apis/**", "/login/**", "/logout", "/actuator/**"))
.authorizeExchange().anyExchange()
.access(new RequestInfoAuthorizationManager(roleService)).and()
.anonymous(spec -> {
@ -98,7 +103,7 @@ public class WebServerSecurityConfig {
}
@Bean
ReactiveUserDetailsService userDetailsService(UserService userService,
DefaultUserDetailService userDetailsService(UserService userService,
RoleService roleService) {
return new DefaultUserDetailService(userService, roleService);
}
@ -117,4 +122,19 @@ public class WebServerSecurityConfig {
return new SuperAdminInitializer(client, passwordEncoder(),
halo.getSecurity().getInitializer());
}
@Bean
RouterFunction<ServerResponse> publicKeyRoute(CryptoService cryptoService) {
return new PublicKeyRouteBuilder(cryptoService).build();
}
@Bean
CryptoService cryptoService(HaloProperties haloProperties) {
return new RsaKeyService(haloProperties.getWorkDir().resolve("keys"));
}
@Bean
RsaKeyScheduledGenerator rsaKeyScheduledGenerator(CryptoService cryptoService) {
return new RsaKeyScheduledGenerator(cryptoService);
}
}

View File

@ -1,103 +0,0 @@
package run.halo.app.security.authentication.formlogin;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import java.util.Map;
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.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.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
public class FormLoginConfigurer implements SecurityConfigurer {
private final ServerResponse.Context context;
public FormLoginConfigurer(ServerResponse.Context context) {
this.context = context;
}
@Override
public void configure(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(new FormLoginSuccessHandler(context))
.authenticationFailureHandler(new FormLoginFailureHandler(context))
;
}
public static class FormLoginSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ServerResponse.Context context;
private final ServerAuthenticationSuccessHandler defaultHandler =
new RedirectServerAuthenticationSuccessHandler("/console/");
public FormLoginSuccessHandler(ServerResponse.Context context) {
this.context = context;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
.matches(webFilterExchange.getExchange())
.flatMap(matchResult -> {
if (matchResult.isMatch()) {
var principal = authentication.getPrincipal();
if (principal instanceof CredentialsContainer credentialsContainer) {
credentialsContainer.eraseCredentials();
}
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(principal)
.flatMap(serverResponse ->
serverResponse.writeTo(webFilterExchange.getExchange(), context));
}
return defaultHandler.onAuthenticationSuccess(webFilterExchange,
authentication);
});
}
}
public static class FormLoginFailureHandler implements ServerAuthenticationFailureHandler {
private final ServerAuthenticationFailureHandler defaultHandler =
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
private final ServerResponse.Context context;
public FormLoginFailureHandler(ServerResponse.Context context) {
this.context = context;
}
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
AuthenticationException exception) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(
webFilterExchange.getExchange())
.flatMap(matchResult -> {
if (matchResult.isMatch()) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"error", exception.getLocalizedMessage()
))
.flatMap(serverResponse -> serverResponse.writeTo(
webFilterExchange.getExchange(), context));
}
return defaultHandler.onAuthenticationFailure(webFilterExchange, exception);
});
}
}
}

View File

@ -0,0 +1,27 @@
package run.halo.app.security.authentication.login;
import reactor.core.publisher.Mono;
public interface CryptoService {
/**
* Generates key pair.
*/
Mono<Void> generateKeys();
/**
* Decrypts message with Base64 format.
*
* @param encryptedMessage is a byte array containing encrypted message.
* @return decrypted message.
*/
Mono<byte[]> decrypt(byte[] encryptedMessage);
/**
* Reads public key.
*
* @return byte array of public key
*/
Mono<byte[]> readPublicKey();
}

View File

@ -0,0 +1,17 @@
package run.halo.app.security.authentication.login;
/**
* InvalidEncryptedMessageException indicates the encrypted message is invalid.
*
* @author johnniang
*/
public class InvalidEncryptedMessageException extends RuntimeException {
public InvalidEncryptedMessageException(String message) {
super(message);
}
public InvalidEncryptedMessageException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,42 @@
package run.halo.app.security.authentication.login;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.util.Base64;
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;
public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter {
private final CryptoService cryptoService;
public LoginAuthenticationConverter(CryptoService cryptoService) {
this.cryptoService = cryptoService;
}
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return super.convert(exchange)
// validate the password
.flatMap(token -> {
var credentials = (String) token.getCredentials();
byte[] credentialsBytes;
try {
credentialsBytes = Base64.getDecoder().decode(credentials);
} catch (IllegalArgumentException e) {
// the credentials are not in valid Base64 scheme
return Mono.error(new BadCredentialsException("Invalid Base64 scheme."));
}
return cryptoService.decrypt(credentialsBytes)
.onErrorMap(InvalidEncryptedMessageException.class,
error -> new BadCredentialsException("Invalid credential.", error))
.map(decryptedCredentials -> new UsernamePasswordAuthenticationToken(
token.getPrincipal(),
new String(decryptedCredentials, UTF_8)));
});
}
}

View File

@ -0,0 +1,149 @@
package run.halo.app.security.authentication.login;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import io.micrometer.observation.ObservationRegistry;
import java.util.Map;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.ObservationReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
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.ui.LogoutPageGeneratingWebFilter;
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.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
public class LoginConfigurer implements SecurityConfigurer {
private final ServerResponse.Context context;
private final ObservationRegistry observationRegistry;
private final ReactiveUserDetailsService userDetailsService;
private final ReactiveUserDetailsPasswordService passwordService;
private final PasswordEncoder passwordEncoder;
private final ServerSecurityContextRepository securityContextRepository;
private final CryptoService cryptoService;
public LoginConfigurer(ServerResponse.Context context,
ObservationRegistry observationRegistry,
ReactiveUserDetailsService userDetailsService,
ReactiveUserDetailsPasswordService passwordService,
PasswordEncoder passwordEncoder,
ServerSecurityContextRepository securityContextRepository,
CryptoService cryptoService) {
this.context = context;
this.observationRegistry = observationRegistry;
this.userDetailsService = userDetailsService;
this.passwordService = passwordService;
this.passwordEncoder = passwordEncoder;
this.securityContextRepository = securityContextRepository;
this.cryptoService = cryptoService;
}
@Override
public void configure(ServerHttpSecurity http) {
// We disable the form login because we will customize the login by ourselves.
// See https://github.com/spring-projects/spring-security/issues/5361 for more.
http.formLogin()
.disable();
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
var filter = new AuthenticationWebFilter(authenticationManager());
filter.setRequiresAuthenticationMatcher(requiresMatcher);
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService));
filter.setSecurityContextRepository(securityContextRepository);
http.addFilterAt(filter, SecurityWebFiltersOrder.FORM_LOGIN);
http.addFilterAt(new LogoutPageGeneratingWebFilter(),
SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING);
}
ReactiveAuthenticationManager authenticationManager() {
var manager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
manager.setPasswordEncoder(passwordEncoder);
manager.setUserDetailsPasswordService(passwordService);
return new ObservationReactiveAuthenticationManager(observationRegistry, manager);
}
public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ServerAuthenticationSuccessHandler defaultHandler =
new RedirectServerAuthenticationSuccessHandler("/console/");
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
.matches(webFilterExchange.getExchange())
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap(matchResult -> {
var principal = authentication.getPrincipal();
if (principal instanceof CredentialsContainer credentialsContainer) {
credentialsContainer.eraseCredentials();
}
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(principal)
.flatMap(serverResponse ->
serverResponse.writeTo(webFilterExchange.getExchange(), context));
})
.switchIfEmpty(
defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
);
}
}
public class LoginFailureHandler implements ServerAuthenticationFailureHandler {
private final ServerAuthenticationFailureHandler defaultHandler =
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
AuthenticationException exception) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(
webFilterExchange.getExchange())
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap(matchResult -> ServerResponse.status(HttpStatus.UNAUTHORIZED)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"error", exception.getLocalizedMessage()
))
.flatMap(serverResponse -> serverResponse.writeTo(
webFilterExchange.getExchange(), context)))
.switchIfEmpty(
defaultHandler.onAuthenticationFailure(webFilterExchange, exception));
}
}
}

View File

@ -0,0 +1,46 @@
package run.halo.app.security.authentication.login;
import java.util.Base64;
import lombok.Data;
import org.springdoc.core.fn.builders.apiresponse.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
public class PublicKeyRouteBuilder {
private final CryptoService cryptoService;
public PublicKeyRouteBuilder(CryptoService cryptoService) {
this.cryptoService = cryptoService;
}
/**
* Builds public key router function.
*
* @return public key router function.
*/
public RouterFunction<ServerResponse> build() {
return SpringdocRouteBuilder.route()
.GET("/login/public-key", request -> cryptoService.readPublicKey()
.flatMap(publicKey -> {
var base64Format = Base64.getEncoder().encodeToString(publicKey);
var response = new PublicKeyResponse();
response.setBase64Format(base64Format);
return ServerResponse.ok()
.bodyValue(response);
}),
builder -> builder.operationId("GetPublicKey")
.description("Read public key for encrypting password.")
.tag("Login")
.response(Builder.responseBuilder()
.implementation(PublicKeyResponse.class))).build();
}
@Data
public static class PublicKeyResponse {
private String base64Format;
}
}

View File

@ -0,0 +1,25 @@
package run.halo.app.security.authentication.login;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
/**
* RsaKeyScheduledGenerator is responsible for periodically generating RSA key pair.
*
* @author johnniang
*/
@Slf4j
public class RsaKeyScheduledGenerator {
private final CryptoService cryptoService;
public RsaKeyScheduledGenerator(CryptoService cryptoService) {
this.cryptoService = cryptoService;
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
void scheduleGeneration() {
cryptoService.generateKeys().block();
}
}

View File

@ -0,0 +1,136 @@
package run.halo.app.security.authentication.login.impl;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Set;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.util.StopWatch;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.security.authentication.login.CryptoService;
import run.halo.app.security.authentication.login.InvalidEncryptedMessageException;
@Slf4j
public class RsaKeyService implements CryptoService {
public static final String ALGORITHM = "RSA";
private final Path privateKeyPath;
private final Path publicKeyPath;
public RsaKeyService(Path dir) {
privateKeyPath = dir.resolve("id_rsa");
publicKeyPath = dir.resolve("id_rsa.pub");
}
@Override
public Mono<Void> generateKeys() {
try {
log.info("Generating RSA keys...");
var stopWatch = new StopWatch("GenerateRSAKeys");
stopWatch.start();
var generator = KeyPairGenerator.getInstance(ALGORITHM);
generator.initialize(2048);
var keyPair = generator.generateKeyPair();
stopWatch.stop();
log.info("Generated RSA keys. Usage: {} ms.", stopWatch.getTotalTimeMillis());
var dataBufferFactory = DefaultDataBufferFactory.sharedInstance;
var privateKeyDataBuffer = Mono.<DataBuffer>fromSupplier(() ->
dataBufferFactory.wrap(keyPair.getPrivate().getEncoded()));
var publicKeyDataBuffer = Mono.<DataBuffer>fromSupplier(() ->
dataBufferFactory.wrap(keyPair.getPublic().getEncoded()));
var writePrivateKey =
DataBufferUtils.write(privateKeyDataBuffer, privateKeyPath, TRUNCATE_EXISTING);
var writePublicKey =
DataBufferUtils.write(publicKeyDataBuffer, publicKeyPath, TRUNCATE_EXISTING);
return Mono.when(
createFileIfNotExist(privateKeyPath),
createFileIfNotExist(publicKeyPath))
.then(Mono.when(
writePrivateKey,
writePublicKey));
} catch (NoSuchAlgorithmException e) {
return Mono.error(e);
}
}
@Override
public Mono<byte[]> decrypt(byte[] encryptedMessage) {
return readKey(privateKeyPath)
.map(privateKeyBytes -> {
var keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
try {
var keyFactory = KeyFactory.getInstance(ALGORITHM);
var privateKey = keyFactory.generatePrivate(keySpec);
var cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(encryptedMessage);
} catch (NoSuchAlgorithmException | InvalidKeySpecException
| NoSuchPaddingException | InvalidKeyException e) {
throw new RuntimeException("Failed to read private key or the key was invalid.",
e);
} catch (IllegalBlockSizeException | BadPaddingException e) {
// invalid encrypted message
throw new InvalidEncryptedMessageException("Invalid encrypted message.", e);
}
});
}
@Override
public Mono<byte[]> readPublicKey() {
return readKey(publicKeyPath);
}
private Mono<byte[]> readKey(Path keyPath) {
var content =
DataBufferUtils.read(keyPath, DefaultDataBufferFactory.sharedInstance, 4096);
return DataBufferUtils.join(content)
.map(dataBuffer -> {
// the byte count won't be too large
var byteBuffer = ByteBuffer.allocate(dataBuffer.readableByteCount());
dataBuffer.toByteBuffer(byteBuffer);
return byteBuffer.array();
});
}
Mono<Void> createFileIfNotExist(Path path) {
if (Files.notExists(path)) {
return Mono.fromRunnable(() -> {
try {
Files.createDirectories(path.getParent());
Files.createFile(path,
PosixFilePermissions.asFileAttribute(Set.of(OWNER_READ, OWNER_WRITE)));
} catch (IOException e) {
// ignore the error
log.warn("Failed to create file for {}", path, e);
}
}).subscribeOn(Schedulers.boundedElastic()).then();
}
return Mono.empty();
}
}

View File

@ -17,5 +17,5 @@ rules:
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ]
verbs: [ "create" ]
- nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*"]
- nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key"]
verbs: [ "get" ]

View File

@ -0,0 +1,90 @@
package run.halo.app.security.authentication.login;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Base64;
import org.junit.jupiter.api.BeforeEach;
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.authentication.BadCredentialsException;
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;
@ExtendWith(MockitoExtension.class)
class LoginAuthenticationConverterTest {
@Mock
ServerWebExchange exchange;
MultiValueMap<String, String> formData;
@InjectMocks
LoginAuthenticationConverter converter;
@Mock
CryptoService cryptoService;
@BeforeEach
void setUp() {
formData = new LinkedMultiValueMap<>();
lenient().when(exchange.getFormData()).thenReturn(Mono.just(formData));
}
@Test
void applyUsernameAndPasswordThenCreatesTokenSuccess() {
var username = "username";
var password = "password";
var decryptedPassword = "decrypted password";
formData.add("username", username);
formData.add("password", Base64.getEncoder().encodeToString(password.getBytes()));
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());
})
.verifyComplete();
verify(cryptoService).decrypt(password.getBytes());
}
@Test
void applyPasswordWithoutBase64FormatThenBadCredentialsException() {
var username = "username";
var password = "+invalid-base64-format-password";
formData.add("username", username);
formData.add("password", password);
StepVerifier.create(converter.convert(exchange))
.verifyError(BadCredentialsException.class);
}
@Test
void applyUsernameAndInvalidPasswordThenBadCredentialsException() {
var username = "username";
var password = "password";
formData.add("username", username);
formData.add("password", Base64.getEncoder().encodeToString(password.getBytes()));
when(cryptoService.decrypt(password.getBytes()))
.thenReturn(Mono.error(() -> new InvalidEncryptedMessageException("invalid message")));
StepVerifier.create(converter.convert(exchange))
.verifyError(BadCredentialsException.class);
verify(cryptoService).decrypt(password.getBytes());
}
}

View File

@ -0,0 +1,51 @@
package run.halo.app.security.authentication.login;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Base64;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
@ExtendWith(MockitoExtension.class)
class PublicKeyRouteBuilderTest {
WebTestClient webClient;
@Mock
CryptoService cryptoService;
@BeforeEach
void setUp() {
webClient = WebTestClient.bindToRouterFunction(
new PublicKeyRouteBuilder(cryptoService).build()
).build();
}
@Test
void shouldReadPublicKey() {
var publicKeyStr = "public-key";
var encoder = Base64.getEncoder();
when(cryptoService.readPublicKey()).thenReturn(Mono.just(publicKeyStr.getBytes()));
webClient.get().uri("/login/public-key")
.exchange()
.expectStatus().isOk()
.expectBody(PublicKeyRouteBuilder.PublicKeyResponse.class)
.consumeWith(result -> {
var response = result.getResponseBody();
assertNotNull(response);
assertEquals(encoder.encodeToString(publicKeyStr.getBytes()),
response.getBase64Format());
});
verify(cryptoService).readPublicKey();
}
}

View File

@ -0,0 +1,111 @@
package run.halo.app.security.authentication.login.impl;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.Exceptions;
import reactor.test.StepVerifier;
import run.halo.app.security.authentication.login.InvalidEncryptedMessageException;
@ExtendWith(MockitoExtension.class)
class RsaKeyServiceTest {
RsaKeyService service;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
service = new RsaKeyService(tempDir);
}
@Test
void shouldGenerateKeyPair()
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
StepVerifier.create(service.generateKeys())
.verifyComplete();
// check the file
byte[] privKeyBytes = Files.readAllBytes(tempDir.resolve("id_rsa"));
byte[] pubKeyBytes = Files.readAllBytes(tempDir.resolve("id_rsa.pub"));
var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes);
var privKeySpec = new PKCS8EncodedKeySpec(privKeyBytes);
var keyFactory = KeyFactory.getInstance(RsaKeyService.ALGORITHM);
var privKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(privKeySpec);
var pubKey = (RSAPublicKey) keyFactory.generatePublic(pubKeySpec);
assertEquals(privKey.getModulus(), pubKey.getModulus());
assertEquals(privKey.getPublicExponent(), pubKey.getPublicExponent());
}
@Test
void shouldReadPublicKey() throws IOException {
StepVerifier.create(service.generateKeys())
.verifyComplete();
var realPubKeyBytes = Files.readAllBytes(tempDir.resolve("id_rsa.pub"));
StepVerifier.create(service.readPublicKey())
.assertNext(bytes -> assertArrayEquals(realPubKeyBytes, bytes))
.verifyComplete();
}
@Test
void shouldDecryptMessageCorrectly() {
StepVerifier.create(service.generateKeys())
.verifyComplete();
final String message = "halo";
var mono = service.readPublicKey()
.map(pubKeyBytes -> {
var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes);
try {
var keyFactory = KeyFactory.getInstance(RsaKeyService.ALGORITHM);
var pubKey = keyFactory.generatePublic(pubKeySpec);
var cipher = Cipher.getInstance(RsaKeyService.ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, pubKey);
return cipher.doFinal(message.getBytes());
} catch (NoSuchAlgorithmException | InvalidKeySpecException
| NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException
| BadPaddingException e) {
throw Exceptions.propagate(e);
}
})
.flatMap(service::decrypt)
.map(String::new);
StepVerifier.create(mono)
.expectNext(message)
.verifyComplete();
}
@Test
void shouldFailToDecryptMessage() {
StepVerifier.create(service.generateKeys())
.verifyComplete();
StepVerifier.create(service.decrypt("invalid-bytes".getBytes()))
.verifyError(InvalidEncryptedMessageException.class);
}
}