Skip to content

Commit

Permalink
feat: require password verification for email updates (#5780)
Browse files Browse the repository at this point in the history
#### What type of PR is this?
/kind feature
/milestone 2.15.x
/area core

#### What this PR does / why we need it:

增加了在用户尝试更新邮箱地址时进行密码验证的步骤。此举提高了安全性,确保邮箱修改操作由经过身份验证的用户执行。

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

#### Does this PR introduce a user-facing change?
```release-note
更新邮箱地址时需进行密码验证
```
  • Loading branch information
guqing committed Apr 26, 2024
1 parent cb6836a commit 1ade849
Show file tree
Hide file tree
Showing 17 changed files with 163 additions and 71 deletions.
6 changes: 5 additions & 1 deletion api-docs/openapi/v3_0/aggregated.json
Original file line number Diff line number Diff line change
Expand Up @@ -19975,13 +19975,17 @@
},
"VerifyCodeRequest": {
"required": [
"code"
"code",
"password"
],
"type": "object",
"properties": {
"code": {
"minLength": 1,
"type": "string"
},
"password": {
"type": "string"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,26 +258,38 @@ private Mono<ServerResponse> verifyEmail(ServerRequest request) {
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Request body is required."))
)
.flatMap(verifyEmailRequest -> ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.map(username -> Tuples.of(username, verifyEmailRequest.code()))
)
.flatMap(tuple2 -> {
var username = tuple2.getT1();
var code = tuple2.getT2();
return Mono.just(username)
.transformDeferred(verificationEmailRateLimiter(username))
.flatMap(name -> emailVerificationService.verify(username, code))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.flatMap(this::doVerifyCode)
.then(ServerResponse.ok().build());
}

private Mono<Void> doVerifyCode(VerifyCodeRequest verifyCodeRequest) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.flatMap(username -> verifyPasswordAndCode(username, verifyCodeRequest));
}

private Mono<Void> verifyPasswordAndCode(String username, VerifyCodeRequest verifyCodeRequest) {
return userService.confirmPassword(username, verifyCodeRequest.password())
.filter(Boolean::booleanValue)
.switchIfEmpty(Mono.error(new UnsatisfiedAttributeValueException(
"Password is incorrect.", "problemDetail.user.password.notMatch", null)))
.flatMap(verified -> verifyEmailCode(username, verifyCodeRequest.code()));
}

private Mono<Void> verifyEmailCode(String username, String code) {
return Mono.just(username)
.transformDeferred(verificationEmailRateLimiter(username))
.flatMap(name -> emailVerificationService.verify(username, code))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
}

public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) String email) {
}

public record VerifyCodeRequest(@Schema(requiredMode = REQUIRED, minLength = 1) String code) {
public record VerifyCodeRequest(
@Schema(requiredMode = REQUIRED) String password,
@Schema(requiredMode = REQUIRED, minLength = 1) String code) {
}

private Mono<ServerResponse> sendEmailVerificationCode(ServerRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ problemDetail.user.email.verify.maxAttempts=Too many verification attempts, plea
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
problemDetail.user.oldPassword.notMatch=The old password does not match.
problemDetail.user.password.notMatch=The password does not match.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
problemDetail.comment.turnedOff=The comment function has been turned off.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试
problemDetail.user.password.unsatisfied=密码不符合规范。
problemDetail.user.username.unsatisfied=用户名不符合规范。
problemDetail.user.oldPassword.notMatch=旧密码不匹配。
problemDetail.user.password.notMatch=密码不匹配。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;

Expand All @@ -43,6 +44,10 @@ class EmailVerificationCodeTest {
ReactiveExtensionClient client;
@Mock
EmailVerificationService emailVerificationService;

@Mock
UserService userService;

@InjectMocks
UserEndpoint endpoint;

Expand Down Expand Up @@ -97,17 +102,19 @@ void sendEmailVerificationCode() {
void verifyEmail() {
when(emailVerificationService.verify(anyString(), anyString()))
.thenReturn(Mono.empty());
when(userService.confirmPassword(anyString(), anyString()))
.thenReturn(Mono.just(true));
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-1"))
.bodyValue(Map.of("code", "fake-code-1", "password", "123456"))
.exchange()
.expectStatus()
.isOk();

// request again to trigger rate limit
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-2"))
.bodyValue(Map.of("code", "fake-code-2", "password", "123456"))
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
Expand Down
1 change: 1 addition & 0 deletions ui/packages/api-client/src/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ models/register-verify-email-request.ts
models/reply-list.ts
models/reply-request.ts
models/reply-spec.ts
models/reply-status.ts
models/reply-vo-list.ts
models/reply-vo.ts
models/reply.ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,16 @@ export const ApiConsoleHaloRunV1alpha1TagApiAxiosParamCreator = function (config
return {
/**
* List Post Tags.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Keyword for searching.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp, name
* @param {number} [page] Page number. Default is 0.
* @param {number} [size] Size number. Default is 0.
* @param {Array<string>} [labelSelector] Label selector. e.g.: hidden!&#x3D;true
* @param {Array<string>} [fieldSelector] Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {string} [keyword] Post tags filtered by keyword.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPostTags: async (fieldSelector?: Array<string>, keyword?: string, labelSelector?: Array<string>, page?: number, size?: number, sort?: Array<string>, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listPostTags: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, keyword?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/tags`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
Expand All @@ -61,28 +61,28 @@ export const ApiConsoleHaloRunV1alpha1TagApiAxiosParamCreator = function (config
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)

if (fieldSelector) {
localVarQueryParameter['fieldSelector'] = fieldSelector;
if (page !== undefined) {
localVarQueryParameter['page'] = page;
}

if (keyword !== undefined) {
localVarQueryParameter['keyword'] = keyword;
if (size !== undefined) {
localVarQueryParameter['size'] = size;
}

if (labelSelector) {
localVarQueryParameter['labelSelector'] = labelSelector;
}

if (page !== undefined) {
localVarQueryParameter['page'] = page;
if (fieldSelector) {
localVarQueryParameter['fieldSelector'] = fieldSelector;
}

if (size !== undefined) {
localVarQueryParameter['size'] = size;
if (sort) {
localVarQueryParameter['sort'] = sort;
}

if (sort) {
localVarQueryParameter['sort'] = Array.from(sort);
if (keyword !== undefined) {
localVarQueryParameter['keyword'] = keyword;
}


Expand All @@ -108,17 +108,17 @@ export const ApiConsoleHaloRunV1alpha1TagApiFp = function(configuration?: Config
return {
/**
* List Post Tags.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Keyword for searching.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp, name
* @param {number} [page] Page number. Default is 0.
* @param {number} [size] Size number. Default is 0.
* @param {Array<string>} [labelSelector] Label selector. e.g.: hidden!&#x3D;true
* @param {Array<string>} [fieldSelector] Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {string} [keyword] Post tags filtered by keyword.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPostTags(fieldSelector?: Array<string>, keyword?: string, labelSelector?: Array<string>, page?: number, size?: number, sort?: Array<string>, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPostTags(fieldSelector, keyword, labelSelector, page, size, sort, options);
async listPostTags(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, keyword?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPostTags(page, size, labelSelector, fieldSelector, sort, keyword, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1TagApi.listPostTags']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
Expand All @@ -140,7 +140,7 @@ export const ApiConsoleHaloRunV1alpha1TagApiFactory = function (configuration?:
* @throws {RequiredError}
*/
listPostTags(requestParameters: ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise<TagList> {
return localVarFp.listPostTags(requestParameters.fieldSelector, requestParameters.keyword, requestParameters.labelSelector, requestParameters.page, requestParameters.size, requestParameters.sort, options).then((request) => request(axios, basePath));
return localVarFp.listPostTags(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.keyword, options).then((request) => request(axios, basePath));
},
};
};
Expand All @@ -152,46 +152,46 @@ export const ApiConsoleHaloRunV1alpha1TagApiFactory = function (configuration?:
*/
export interface ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest {
/**
* Field selector for filtering.
* @type {Array<string>}
* Page number. Default is 0.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly fieldSelector?: Array<string>
readonly page?: number

/**
* Keyword for searching.
* @type {string}
* Size number. Default is 0.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly keyword?: string
readonly size?: number

/**
* Label selector for filtering.
* Label selector. e.g.: hidden!&#x3D;true
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly labelSelector?: Array<string>

/**
* The page number. Zero indicates no page.
* @type {number}
* Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly page?: number
readonly fieldSelector?: Array<string>

/**
* Size of one page. Zero indicates no limit.
* @type {number}
* Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly size?: number
readonly sort?: Array<string>

/**
* Sort property and direction of the list result. Supported fields: creationTimestamp, name
* @type {Array<string>}
* Post tags filtered by keyword.
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly sort?: Array<string>
readonly keyword?: string
}

/**
Expand All @@ -209,7 +209,7 @@ export class ApiConsoleHaloRunV1alpha1TagApi extends BaseAPI {
* @memberof ApiConsoleHaloRunV1alpha1TagApi
*/
public listPostTags(requestParameters: ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest = {}, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1TagApiFp(this.configuration).listPostTags(requestParameters.fieldSelector, requestParameters.keyword, requestParameters.labelSelector, requestParameters.page, requestParameters.size, requestParameters.sort, options).then((request) => request(this.axios, this.basePath));
return ApiConsoleHaloRunV1alpha1TagApiFp(this.configuration).listPostTags(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.keyword, options).then((request) => request(this.axios, this.basePath));
}
}

6 changes: 6 additions & 0 deletions ui/packages/api-client/src/models/comment-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface CommentStatus {
* @memberof CommentStatus
*/
'lastReplyTime'?: string;
/**
*
* @type {number}
* @memberof CommentStatus
*/
'observedVersion'?: number;
/**
*
* @type {number}
Expand Down
1 change: 1 addition & 0 deletions ui/packages/api-client/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export * from './reply';
export * from './reply-list';
export * from './reply-request';
export * from './reply-spec';
export * from './reply-status';
export * from './reply-vo';
export * from './reply-vo-list';
export * from './reset-password-request';
Expand Down
3 changes: 1 addition & 2 deletions ui/packages/api-client/src/models/plugin-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ export const PluginStatusLastProbeStateEnum = {
Resolved: 'RESOLVED',
Started: 'STARTED',
Stopped: 'STOPPED',
Failed: 'FAILED',
Unloaded: 'UNLOADED'
Failed: 'FAILED'
} as const;

export type PluginStatusLastProbeStateEnum = typeof PluginStatusLastProbeStateEnum[keyof typeof PluginStatusLastProbeStateEnum];
Expand Down
30 changes: 30 additions & 0 deletions ui/packages/api-client/src/models/reply-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* 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 ReplyStatus
*/
export interface ReplyStatus {
/**
*
* @type {number}
* @memberof ReplyStatus
*/
'observedVersion'?: number;
}

9 changes: 9 additions & 0 deletions ui/packages/api-client/src/models/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { Metadata } from './metadata';
// May contain unused imports in some cases
// @ts-ignore
import { ReplySpec } from './reply-spec';
// May contain unused imports in some cases
// @ts-ignore
import { ReplyStatus } from './reply-status';

/**
*
Expand Down Expand Up @@ -50,5 +53,11 @@ export interface Reply {
* @memberof Reply
*/
'spec': ReplySpec;
/**
*
* @type {ReplyStatus}
* @memberof Reply
*/
'status': ReplyStatus;
}

Loading

0 comments on commit 1ade849

Please sign in to comment.