Add an API to change password of user (#2250)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.0

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

This PR provides an API to change password of user. If the username is equal to `-`, we will change the password of current login user. Otherwise, we update the password according the request URI.

Here is an example:

- Request

```bash
curl -X 'PUT' \
  'http://localhost:8090/apis/api.halo.run/v1alpha1/users/-/password' \
  -H 'accept: */*' \
  -H 'Content-Type: */*' \
  -d '{
  "password": "openhalo"
}'
```

- Response

```json
{
  "spec": {
    "displayName": "Administrator",
    "email": "admin@halo.run",
    "password": "{bcrypt}$2a$10$/v8/nbxoUFGBDoWfOF2NHOHk.2RS0OFfS5AtN2g/mCGjScX19KvSG",
    "registeredAt": "2022-07-15T07:50:25.151513387Z",
    "twoFactorAuthEnabled": false,
    "disabled": false
  },
  "apiVersion": "v1alpha1",
  "kind": "User",
  "metadata": {
    "name": "admin",
    "annotations": {
      "rbac.authorization.halo.run/role-names": "[\"super-role\"]"
    },
    "version": 5,
    "creationTimestamp": "2022-07-15T07:50:25.255909669Z"
  }
}
```
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:

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

```release-note
None
```
pull/2249/head
John Niang 2022-07-18 12:18:11 +08:00 committed by GitHub
parent 49ea6fbdec
commit 1571b9bcf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 271 additions and 72 deletions

View File

@ -46,6 +46,73 @@ public class UserEndpoint implements CustomEndpoint {
this.userService = userService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "api.halo.run/v1alpha1/User";
return SpringdocRouteBuilder.route()
.GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
.description("Get current user detail")
.tag(tag)
.response(responseBuilder().implementation(User.class)))
.POST("/users/{name}/permissions", this::grantPermission,
builder -> builder.operationId("GrantPermission")
.description("Grant permissions to user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.requestBody(requestBodyBuilder()
.required(true)
.implementation(GrantRequest.class))
.response(responseBuilder().implementation(User.class)))
.GET("/users/{name}/permissions", this::getUserPermission,
builder -> builder.operationId("GetPermissions")
.description("Get permissions of user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.response(responseBuilder().implementation(UserPermission.class)))
.PUT("/users/{name}/password", this::changePassword,
builder -> builder.operationId("ChangePassword")
.description("Change password of user.")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description(
"Name of user. If the name is equal to '-', it will change the "
+ "password of current user.")
.required(true))
.requestBody(requestBodyBuilder()
.required(true)
.implementation(ChangePasswordRequest.class))
.response(responseBuilder()
.implementation(User.class))
)
.build();
}
Mono<ServerResponse> changePassword(ServerRequest request) {
final var nameInPath = request.pathVariable("name");
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> "-".equals(nameInPath) ? ctx.getAuthentication().getName() : nameInPath)
.flatMap(username -> request.bodyToMono(ChangePasswordRequest.class)
.switchIfEmpty(Mono.defer(() ->
Mono.error(new ServerWebInputException("Request body is empty"))))
.flatMap(changePasswordRequest -> {
var password = changePasswordRequest.password();
// encode password
return userService.updateWithRawPassword(username, password);
}))
.flatMap(updatedUser -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(updatedUser));
}
record ChangePasswordRequest(
@Schema(description = "New password.", required = true, minLength = 6)
String password) {
}
@NonNull
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
@ -63,7 +130,8 @@ public class UserEndpoint implements CustomEndpoint {
Mono<ServerResponse> grantPermission(ServerRequest request) {
var username = request.pathVariable("name");
return request.bodyToMono(GrantRequest.class)
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body is empty")))
.switchIfEmpty(
Mono.error(() -> new ServerWebInputException("Request body is empty")))
.flatMap(grant -> {
// preflight check
client.fetch(User.class, username)
@ -111,35 +179,6 @@ public class UserEndpoint implements CustomEndpoint {
record GrantRequest(Set<String> roles) {
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "api.halo.run/v1alpha1/User";
return SpringdocRouteBuilder.route()
.GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
.description("Get current user detail")
.tag(tag)
.response(responseBuilder().implementation(User.class)))
.POST("/users/{name}/permissions", this::grantPermission,
builder -> builder.operationId("GrantPermission")
.description("Grant permissions to user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.requestBody(
requestBodyBuilder().required(true).implementation(GrantRequest.class))
.response(responseBuilder().implementation(User.class)))
.GET("/users/{name}/permissions", this::getUserPermission,
builder -> builder.operationId("GetPermissions")
.description("Get permissions of user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.response(responseBuilder().implementation(UserPermission.class)))
.build();
}
@NonNull
private Mono<ServerResponse> getUserPermission(ServerRequest request) {
String name = request.pathVariable("name");
@ -150,11 +189,11 @@ public class UserEndpoint implements CustomEndpoint {
})
.map(roles -> {
Set<String> uiPermissions = roles.stream()
.map(role -> role.getMetadata().getAnnotations())
.filter(Objects::nonNull)
.map(this::mergeUiPermissions)
.flatMap(Set::stream)
.collect(Collectors.toSet());
.map(role -> role.getMetadata().getAnnotations())
.filter(Objects::nonNull)
.map(this::mergeUiPermissions)
.flatMap(Set::stream)
.collect(Collectors.toSet());
return new UserPermission(roles, uiPermissions);
})
.flatMap(result -> ServerResponse.ok()

View File

@ -9,7 +9,10 @@ public interface UserService {
Mono<User> getUser(String username);
@Deprecated
Mono<Void> updatePassword(String username, String newPassword);
Mono<User> updateWithRawPassword(String username, String rawPassword);
Flux<Role> listRoles(String username);
}

View File

@ -3,7 +3,10 @@ package run.halo.app.core.extension.service;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import java.util.Objects;
import java.util.function.Predicate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
@ -16,8 +19,11 @@ public class UserServiceImpl implements UserService {
private final ExtensionClient client;
public UserServiceImpl(ExtensionClient client) {
private final PasswordEncoder passwordEncoder;
public UserServiceImpl(ExtensionClient client, PasswordEncoder passwordEncoder) {
this.client = client;
this.passwordEncoder = passwordEncoder;
}
@Override
@ -35,6 +41,27 @@ public class UserServiceImpl implements UserService {
.then();
}
@Override
public Mono<User> updateWithRawPassword(String username, String rawPassword) {
return getUser(username)
.filter(Predicate.not(hasPassword().and(passwordMatches(rawPassword))))
.flatMap(user -> {
// TODO Validate the password
user.getSpec().setPassword(passwordEncoder.encode(rawPassword));
client.update(user);
// get the latest user
return getUser(username);
});
}
private Predicate<User> hasPassword() {
return user -> StringUtils.hasText(user.getSpec().getPassword());
}
private Predicate<User> passwordMatches(String rawPassword) {
return user -> passwordEncoder.matches(rawPassword, user.getSpec().getPassword());
}
@Override
public Flux<Role> listRoles(String name) {
return Flux.fromStream(client.list(RoleBinding.class, containsUser(name), null)

View File

@ -1,7 +1,6 @@
package run.halo.app.core.extension.endpoint;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
@ -16,6 +15,7 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -26,6 +26,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
@ -37,6 +38,7 @@ import run.halo.app.infra.utils.JsonUtils;
@SpringBootTest
@AutoConfigureWebTestClient
@WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role")
class UserEndpointTest {
@Autowired
@ -61,41 +63,79 @@ class UserEndpointTest {
.build();
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole(anyString())).thenReturn(role);
// prevent from initializing the super admin.
when(client.fetch(User.class, "admin")).thenReturn(Optional.of(mock(User.class)));
}
@Test
@WithMockUser("fake-user")
void shouldResponseErrorIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().is5xxServerError();
}
@Test
@WithMockUser("fake-user")
void shouldGetCurrentUserDetail() {
var metadata = new Metadata();
metadata.setName("fake-user");
var user = new User();
user.setMetadata(metadata);
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(user));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(User.class)
.isEqualTo(user);
when(roleService.getRole("fake-super-role")).thenReturn(role);
}
@Nested
@DisplayName("GetUserDetail")
class GetUserDetailTest {
@Test
void shouldResponseErrorIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().is5xxServerError();
}
@Test
void shouldGetCurrentUserDetail() {
var metadata = new Metadata();
metadata.setName("fake-user");
var user = new User();
user.setMetadata(metadata);
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(user));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(User.class)
.isEqualTo(user);
}
}
@Nested
@DisplayName("ChangePassword")
class ChangePasswordTest {
@Test
void shouldUpdateMyPasswordCorrectly() {
var user = new User();
when(userService.updateWithRawPassword("fake-user", "new-password"))
.thenReturn(Mono.just(user));
webClient.put().uri("/apis/api.halo.run/v1alpha1/users/-/password")
.bodyValue(new UserEndpoint.ChangePasswordRequest("new-password"))
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(user);
verify(userService, times(1)).updateWithRawPassword("fake-user", "new-password");
}
@Test
void shouldUpdateOtherPasswordCorrectly() {
var user = new User();
when(userService.updateWithRawPassword("another-fake-user", "new-password"))
.thenReturn(Mono.just(user));
webClient.put().uri("/apis/api.halo.run/v1alpha1/users/another-fake-user/password")
.bodyValue(new UserEndpoint.ChangePasswordRequest("new-password"))
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(user);
verify(userService, times(1)).updateWithRawPassword("another-fake-user",
"new-password");
}
}
@Nested
@DisplayName("GrantPermission")
class GrantPermissionEndpointTest {
@Test
@WithMockUser("fake-user")
void shouldGetBadRequestIfRequestBodyIsEmpty() {
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
@ -108,7 +148,6 @@ class UserEndpointTest {
}
@Test
@WithMockUser("fake-user")
void shouldGetNotFoundIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(mock(Role.class)));
@ -124,7 +163,6 @@ class UserEndpointTest {
}
@Test
@WithMockUser("fake-user")
void shouldGetNotFoundIfRoleNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.empty());
@ -140,7 +178,6 @@ class UserEndpointTest {
}
@Test
@WithMockUser("fake-user")
void shouldCreateRoleBindingIfNotExist() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
var role = mock(Role.class);
@ -160,7 +197,6 @@ class UserEndpointTest {
}
@Test
@WithMockUser("fake-user")
void shouldDeleteRoleBindingIfNotProvided() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
var role = mock(Role.class);
@ -179,12 +215,11 @@ class UserEndpointTest {
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, times(1))
.delete(argThat(binding -> binding.getMetadata().getName()
.equals(roleBinding.getMetadata().getName())));
.equals(roleBinding.getMetadata().getName())));
verify(client, never()).update(isA(RoleBinding.class));
}
@Test
@WithMockUser("fake-user")
void shouldGetPermission() {
Role roleA = JsonUtils.jsonToObject("""
{

View File

@ -2,20 +2,28 @@ package run.halo.app.core.extension.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.crypto.password.PasswordEncoder;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
@ -30,6 +38,9 @@ class UserServiceImplTest {
@Mock
ExtensionClient client;
@Mock
PasswordEncoder passwordEncoder;
@InjectMocks
UserServiceImpl userService;
@ -194,4 +205,88 @@ class UserServiceImplTest {
JsonUtils.jsonToObject(bindB, RoleBinding.class),
JsonUtils.jsonToObject(bindC, RoleBinding.class));
}
@Nested
@DisplayName("UpdateWithRawPassword")
class UpdateWithRawPasswordTest {
@Test
void shouldUpdatePasswordWithDifferentPassword() {
userService = spy(userService);
doReturn(
Mono.just(createUser("fake-password")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
when(passwordEncoder.matches("new-password", "fake-password")).thenReturn(false);
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNext(createUser("new-password"))
.verifyComplete();
verify(passwordEncoder, times(1)).matches("new-password", "fake-password");
verify(passwordEncoder, times(1)).encode("new-password");
verify(userService, times(2)).getUser("fake-user");
}
@Test
void shouldUpdatePasswordIfNoPasswordBefore() {
userService = spy(userService);
doReturn(
Mono.just(createUser("")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNext(createUser("new-password"))
.verifyComplete();
verify(passwordEncoder, never()).matches(anyString(), anyString());
verify(passwordEncoder, times(1)).encode("new-password");
verify(userService, times(2)).getUser("fake-user");
}
@Test
void shouldDoNothingIfPasswordNotChanged() {
userService = spy(userService);
doReturn(
Mono.just(createUser("fake-password")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
when(passwordEncoder.matches("fake-password", "fake-password")).thenReturn(true);
StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake-password"))
.expectNextCount(0)
.verifyComplete();
verify(passwordEncoder, times(1)).matches("fake-password", "fake-password");
verify(passwordEncoder, never()).encode("fake-password");
verify(userService, times(1)).getUser("fake-user");
}
@Test
void shouldDoNothingIfUserNotFound() {
userService = spy(userService);
doReturn(Mono.empty()).when(userService).getUser("fake-user");
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNextCount(0)
.verifyComplete();
verify(passwordEncoder, never()).matches(anyString(), anyString());
verify(passwordEncoder, never()).encode(anyString());
verify(userService, times(1)).getUser(anyString());
}
User createUser(String password) {
var user = new User();
user.setSpec(new User.UserSpec());
user.getSpec().setPassword(password);
return user;
}
}
}