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-grid-layout": "3.0.0-beta1",
|
||||||
"vue-i18n": "^9.2.2",
|
"vue-i18n": "^9.2.2",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0",
|
||||||
|
"jsencrypt": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.25.2",
|
"@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-single-page-api.ts
|
||||||
api/content-halo-run-v1alpha1-snapshot-api.ts
|
api/content-halo-run-v1alpha1-snapshot-api.ts
|
||||||
api/content-halo-run-v1alpha1-tag-api.ts
|
api/content-halo-run-v1alpha1-tag-api.ts
|
||||||
|
api/login-api.ts
|
||||||
api/metrics-halo-run-v1alpha1-counter-api.ts
|
api/metrics-halo-run-v1alpha1-counter-api.ts
|
||||||
api/plugin-halo-run-v1alpha1-plugin-api.ts
|
api/plugin-halo-run-v1alpha1-plugin-api.ts
|
||||||
api/plugin-halo-run-v1alpha1-reverse-proxy-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-spec.ts
|
||||||
models/post-status.ts
|
models/post-status.ts
|
||||||
models/post.ts
|
models/post.ts
|
||||||
|
models/public-key-response.ts
|
||||||
models/ref.ts
|
models/ref.ts
|
||||||
models/reply-list.ts
|
models/reply-list.ts
|
||||||
models/reply-request.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-single-page-api";
|
||||||
export * from "./api/content-halo-run-v1alpha1-snapshot-api";
|
export * from "./api/content-halo-run-v1alpha1-snapshot-api";
|
||||||
export * from "./api/content-halo-run-v1alpha1-tag-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/metrics-halo-run-v1alpha1-counter-api";
|
||||||
export * from "./api/plugin-halo-run-v1alpha1-plugin-api";
|
export * from "./api/plugin-halo-run-v1alpha1-plugin-api";
|
||||||
export * from "./api/plugin-halo-run-v1alpha1-reverse-proxy-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 type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
|
||||||
import globalAxios 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-request";
|
||||||
export * from "./post-spec";
|
export * from "./post-spec";
|
||||||
export * from "./post-status";
|
export * from "./post-status";
|
||||||
|
export * from "./public-key-response";
|
||||||
export * from "./ref";
|
export * from "./ref";
|
||||||
export * from "./reply";
|
export * from "./reply";
|
||||||
export * from "./reply-list";
|
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
|
fuse.js: ^6.6.2
|
||||||
husky: ^8.0.2
|
husky: ^8.0.2
|
||||||
jsdom: ^20.0.3
|
jsdom: ^20.0.3
|
||||||
|
jsencrypt: ^3.3.2
|
||||||
lint-staged: ^13.1.0
|
lint-staged: ^13.1.0
|
||||||
lodash.clonedeep: ^4.5.0
|
lodash.clonedeep: ^4.5.0
|
||||||
lodash.debounce: ^4.0.8
|
lodash.debounce: ^4.0.8
|
||||||
|
@ -139,6 +140,7 @@ importers:
|
||||||
fastq: 1.15.0
|
fastq: 1.15.0
|
||||||
floating-vue: 2.0.0-beta.20_vue@3.2.45
|
floating-vue: 2.0.0-beta.20_vue@3.2.45
|
||||||
fuse.js: 6.6.2
|
fuse.js: 6.6.2
|
||||||
|
jsencrypt: 3.3.2
|
||||||
lodash.clonedeep: 4.5.0
|
lodash.clonedeep: 4.5.0
|
||||||
lodash.debounce: 4.0.8
|
lodash.debounce: 4.0.8
|
||||||
lodash.isequal: 4.5.0
|
lodash.isequal: 4.5.0
|
||||||
|
@ -4616,7 +4618,7 @@ packages:
|
||||||
/axios/0.26.1:
|
/axios/0.26.1:
|
||||||
resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
|
resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.2
|
follow-redirects: 1.15.2_debug@4.3.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -4624,7 +4626,7 @@ packages:
|
||||||
/axios/0.27.2:
|
/axios/0.27.2:
|
||||||
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
|
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.2
|
follow-redirects: 1.15.2_debug@4.3.2
|
||||||
form-data: 4.0.0
|
form-data: 4.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
@ -5431,7 +5433,6 @@ packages:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/debug/4.3.4:
|
/debug/4.3.4:
|
||||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||||
|
@ -6669,15 +6670,6 @@ packages:
|
||||||
vue-resize: 2.0.0-alpha.1_vue@3.2.45
|
vue-resize: 2.0.0-alpha.1_vue@3.2.45
|
||||||
dev: false
|
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:
|
/follow-redirects/1.15.2_debug@4.3.2:
|
||||||
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
|
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
@ -6688,7 +6680,6 @@ packages:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.2
|
debug: 4.3.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/foreground-child/2.0.0:
|
/foreground-child/2.0.0:
|
||||||
resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
|
resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
|
||||||
|
@ -7671,6 +7662,10 @@ packages:
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/jsencrypt/3.3.2:
|
||||||
|
resolution: {integrity: sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/jsesc/0.5.0:
|
/jsesc/0.5.0:
|
||||||
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
@ -8236,7 +8231,6 @@ packages:
|
||||||
|
|
||||||
/ms/2.1.2:
|
/ms/2.1.2:
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/ms/2.1.3:
|
/ms/2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
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 { onMounted, ref } from "vue";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import { submitForm } from "@formkit/core";
|
import { submitForm } from "@formkit/core";
|
||||||
|
import { JSEncrypt } from "jsencrypt";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "succeed"): void;
|
(event: "succeed"): void;
|
||||||
|
@ -39,9 +41,17 @@ const handleLogin = async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
|
const { data: publicKey } = await apiClient.login.getPublicKey();
|
||||||
|
|
||||||
|
const encrypt = new JSEncrypt();
|
||||||
|
encrypt.setPublicKey(publicKey.base64Format as string);
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${import.meta.env.VITE_API_URL}/login`,
|
`${import.meta.env.VITE_API_URL}/login`,
|
||||||
qs.stringify(loginForm.value),
|
qs.stringify({
|
||||||
|
...loginForm.value,
|
||||||
|
password: encrypt.encrypt(loginForm.value.password),
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -66,7 +76,7 @@ const handleLogin = async () => {
|
||||||
|
|
||||||
if (e.response?.status === 403) {
|
if (e.response?.status === 403) {
|
||||||
Toast.warning("CSRF Token 失效,请重新尝试", { duration: 5000 });
|
Toast.warning("CSRF Token 失效,请重新尝试", { duration: 5000 });
|
||||||
handleGenerateToken();
|
await handleGenerateToken();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
V1alpha1SettingApi,
|
V1alpha1SettingApi,
|
||||||
V1alpha1UserApi,
|
V1alpha1UserApi,
|
||||||
V1alpha1AnnotationSettingApi,
|
V1alpha1AnnotationSettingApi,
|
||||||
|
LoginApi,
|
||||||
} from "@halo-dev/api-client";
|
} from "@halo-dev/api-client";
|
||||||
import type { AxiosError, AxiosInstance } from "axios";
|
import type { AxiosError, AxiosInstance } from "axios";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
@ -184,6 +185,7 @@ function setupApiClient(axios: AxiosInstance) {
|
||||||
baseURL,
|
baseURL,
|
||||||
axios
|
axios
|
||||||
),
|
),
|
||||||
|
login: new LoginApi(undefined, baseURL, axios),
|
||||||
indices: new ApiConsoleHaloRunV1alpha1IndicesApi(undefined, baseURL, axios),
|
indices: new ApiConsoleHaloRunV1alpha1IndicesApi(undefined, baseURL, axios),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ public class SwaggerConfig {
|
||||||
return GroupedOpenApi.builder()
|
return GroupedOpenApi.builder()
|
||||||
.group("all-api")
|
.group("all-api")
|
||||||
.displayName("All APIs")
|
.displayName("All APIs")
|
||||||
.pathsToMatch("/api/**", "/apis/**")
|
.pathsToMatch("/api/**", "/apis/**", "/login/**")
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
import org.springframework.security.core.authority.AuthorityUtils;
|
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.factory.PasswordEncoderFactories;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
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.context.WebSessionServerSecurityContextRepository;
|
||||||
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
|
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.RoleService;
|
||||||
import run.halo.app.core.extension.service.UserService;
|
import run.halo.app.core.extension.service.UserService;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
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.DefaultUserDetailService;
|
||||||
import run.halo.app.security.SuperAdminInitializer;
|
import run.halo.app.security.SuperAdminInitializer;
|
||||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
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;
|
import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -52,7 +57,7 @@ public class WebServerSecurityConfig {
|
||||||
ServerSecurityContextRepository securityContextRepository) {
|
ServerSecurityContextRepository securityContextRepository) {
|
||||||
|
|
||||||
http.securityMatcher(
|
http.securityMatcher(
|
||||||
pathMatchers("/api/**", "/apis/**", "/login", "/logout", "/actuator/**"))
|
pathMatchers("/api/**", "/apis/**", "/login/**", "/logout", "/actuator/**"))
|
||||||
.authorizeExchange().anyExchange()
|
.authorizeExchange().anyExchange()
|
||||||
.access(new RequestInfoAuthorizationManager(roleService)).and()
|
.access(new RequestInfoAuthorizationManager(roleService)).and()
|
||||||
.anonymous(spec -> {
|
.anonymous(spec -> {
|
||||||
|
@ -98,7 +103,7 @@ public class WebServerSecurityConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
ReactiveUserDetailsService userDetailsService(UserService userService,
|
DefaultUserDetailService userDetailsService(UserService userService,
|
||||||
RoleService roleService) {
|
RoleService roleService) {
|
||||||
return new DefaultUserDetailService(userService, roleService);
|
return new DefaultUserDetailService(userService, roleService);
|
||||||
}
|
}
|
||||||
|
@ -117,4 +122,19 @@ public class WebServerSecurityConfig {
|
||||||
return new SuperAdminInitializer(client, passwordEncoder(),
|
return new SuperAdminInitializer(client, passwordEncoder(),
|
||||||
halo.getSecurity().getInitializer());
|
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: [ "*" ]
|
verbs: [ "*" ]
|
||||||
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ]
|
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ]
|
||||||
verbs: [ "create" ]
|
verbs: [ "create" ]
|
||||||
- nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*"]
|
- nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key"]
|
||||||
verbs: [ "get" ]
|
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