mirror of https://github.com/halo-dev/halo
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
parent
df2300144e
commit
d192b8c956
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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(/\/+$/, "");
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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==}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ public class SwaggerConfig {
|
|||
return GroupedOpenApi.builder()
|
||||
.group("all-api")
|
||||
.displayName("All APIs")
|
||||
.pathsToMatch("/api/**", "/apis/**")
|
||||
.pathsToMatch("/api/**", "/apis/**", "/login/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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" ]
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue