diff --git a/console/package.json b/console/package.json index 77c101b8f..565620dce 100644 --- a/console/package.json +++ b/console/package.json @@ -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", diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES index 95afb0461..3e409619d 100644 --- a/console/packages/api-client/src/.openapi-generator/FILES +++ b/console/packages/api-client/src/.openapi-generator/FILES @@ -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 diff --git a/console/packages/api-client/src/api.ts b/console/packages/api-client/src/api.ts index 85df17a35..6933b4af9 100644 --- a/console/packages/api-client/src/api.ts +++ b/console/packages/api-client/src/api.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"; diff --git a/console/packages/api-client/src/api/login-api.ts b/console/packages/api-client/src/api/login-api.ts new file mode 100644 index 000000000..414a81666 --- /dev/null +++ b/console/packages/api-client/src/api/login-api.ts @@ -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 => { + 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 + > { + 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 { + 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)); + } +} diff --git a/console/packages/api-client/src/base.ts b/console/packages/api-client/src/base.ts index 668b776e4..1159a2889 100644 --- a/console/packages/api-client/src/base.ts +++ b/console/packages/api-client/src/base.ts @@ -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(/\/+$/, ""); /** * diff --git a/console/packages/api-client/src/models/index.ts b/console/packages/api-client/src/models/index.ts index feff9f27e..86698b354 100644 --- a/console/packages/api-client/src/models/index.ts +++ b/console/packages/api-client/src/models/index.ts @@ -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"; diff --git a/console/packages/api-client/src/models/public-key-response.ts b/console/packages/api-client/src/models/public-key-response.ts new file mode 100644 index 000000000..3220f46be --- /dev/null +++ b/console/packages/api-client/src/models/public-key-response.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface PublicKeyResponse + */ +export interface PublicKeyResponse { + /** + * + * @type {string} + * @memberof PublicKeyResponse + */ + base64Format?: string; +} diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index 276ac0ec7..81948c079 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -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==} diff --git a/console/src/components/login/LoginForm.vue b/console/src/components/login/LoginForm.vue index 544674e53..4f2082112 100644 --- a/console/src/components/login/LoginForm.vue +++ b/console/src/components/login/LoginForm.vue @@ -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; } diff --git a/console/src/utils/api-client.ts b/console/src/utils/api-client.ts index 11a856613..71949b3a0 100644 --- a/console/src/utils/api-client.ts +++ b/console/src/utils/api-client.ts @@ -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), }; } diff --git a/src/main/java/run/halo/app/config/SwaggerConfig.java b/src/main/java/run/halo/app/config/SwaggerConfig.java index 4d651b2bb..161c101fa 100644 --- a/src/main/java/run/halo/app/config/SwaggerConfig.java +++ b/src/main/java/run/halo/app/config/SwaggerConfig.java @@ -80,7 +80,7 @@ public class SwaggerConfig { return GroupedOpenApi.builder() .group("all-api") .displayName("All APIs") - .pathsToMatch("/api/**", "/apis/**") + .pathsToMatch("/api/**", "/apis/**", "/login/**") .build(); } diff --git a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 78507095e..9661084a4 100644 --- a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -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 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); + } } diff --git a/src/main/java/run/halo/app/security/authentication/formlogin/FormLoginConfigurer.java b/src/main/java/run/halo/app/security/authentication/formlogin/FormLoginConfigurer.java deleted file mode 100644 index 42211d533..000000000 --- a/src/main/java/run/halo/app/security/authentication/formlogin/FormLoginConfigurer.java +++ /dev/null @@ -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 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 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); - }); - } - - } -} diff --git a/src/main/java/run/halo/app/security/authentication/login/CryptoService.java b/src/main/java/run/halo/app/security/authentication/login/CryptoService.java new file mode 100644 index 000000000..99207b5ed --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/CryptoService.java @@ -0,0 +1,27 @@ +package run.halo.app.security.authentication.login; + +import reactor.core.publisher.Mono; + +public interface CryptoService { + + /** + * Generates key pair. + */ + Mono generateKeys(); + + /** + * Decrypts message with Base64 format. + * + * @param encryptedMessage is a byte array containing encrypted message. + * @return decrypted message. + */ + Mono decrypt(byte[] encryptedMessage); + + /** + * Reads public key. + * + * @return byte array of public key + */ + Mono readPublicKey(); + +} diff --git a/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java b/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java new file mode 100644 index 000000000..3e7cb4dc4 --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/InvalidEncryptedMessageException.java @@ -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); + } +} diff --git a/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java new file mode 100644 index 000000000..9e2d825ca --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java @@ -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 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))); + }); + } +} diff --git a/src/main/java/run/halo/app/security/authentication/login/LoginConfigurer.java b/src/main/java/run/halo/app/security/authentication/login/LoginConfigurer.java new file mode 100644 index 000000000..9ae1d76bb --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/LoginConfigurer.java @@ -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 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 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)); + } + + } +} diff --git a/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java b/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java new file mode 100644 index 000000000..ec6be59e1 --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilder.java @@ -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 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; + + } +} diff --git a/src/main/java/run/halo/app/security/authentication/login/RsaKeyScheduledGenerator.java b/src/main/java/run/halo/app/security/authentication/login/RsaKeyScheduledGenerator.java new file mode 100644 index 000000000..77221273f --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/RsaKeyScheduledGenerator.java @@ -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(); + } +} diff --git a/src/main/java/run/halo/app/security/authentication/login/impl/RsaKeyService.java b/src/main/java/run/halo/app/security/authentication/login/impl/RsaKeyService.java new file mode 100644 index 000000000..f2f47598f --- /dev/null +++ b/src/main/java/run/halo/app/security/authentication/login/impl/RsaKeyService.java @@ -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 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.fromSupplier(() -> + dataBufferFactory.wrap(keyPair.getPrivate().getEncoded())); + var publicKeyDataBuffer = Mono.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 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 readPublicKey() { + return readKey(publicKeyPath); + } + + private Mono 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 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(); + } +} diff --git a/src/main/resources/extensions/role-template-anonymous.yaml b/src/main/resources/extensions/role-template-anonymous.yaml index 67682b106..09dca4eea 100644 --- a/src/main/resources/extensions/role-template-anonymous.yaml +++ b/src/main/resources/extensions/role-template-anonymous.yaml @@ -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" ] diff --git a/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java new file mode 100644 index 000000000..e55e2b01f --- /dev/null +++ b/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java @@ -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 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()); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java b/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java new file mode 100644 index 000000000..8b970179f --- /dev/null +++ b/src/test/java/run/halo/app/security/authentication/login/PublicKeyRouteBuilderTest.java @@ -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(); + } + +} diff --git a/src/test/java/run/halo/app/security/authentication/login/impl/RsaKeyServiceTest.java b/src/test/java/run/halo/app/security/authentication/login/impl/RsaKeyServiceTest.java new file mode 100644 index 000000000..c79be3e11 --- /dev/null +++ b/src/test/java/run/halo/app/security/authentication/login/impl/RsaKeyServiceTest.java @@ -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); + } +} \ No newline at end of file