Add MapStruct to remove Boilerplate-Code. Adjust Authorization.

This commit is contained in:
Armin Wolf 2024-04-22 10:49:04 +02:00
parent f0361c4ed6
commit e94d23d760
23 changed files with 230 additions and 124 deletions

32
pom.xml
View File

@ -15,6 +15,7 @@
<description>Demo project for Spring Boot</description>
<properties>
<java.version>21</java.version>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>
<dependencies>
<dependency>
@ -48,6 +49,11 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@ -64,6 +70,11 @@
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
<build>
@ -71,15 +82,28 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<excludes>
<exclude>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
<version>1.18.32</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

View File

@ -13,7 +13,6 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.util.List;
import java.util.Set;
@SpringBootApplication
@ -38,11 +37,13 @@ public class Application {
CommandLineRunner run() {
return args -> {
LOG.info("Creating default ADMIN Account.");
roleService.init();
Set<Role> allRoles = roleService.getAllRoles();
User adminAccount = userService.createUser("Admin", "Admin", "admin", "admin", allRoles);
User adminAccount = userService
.createUser("Admin",
"Admin",
"admin",
"admin",
Set.of(roleService.getDefaultRole(), roleService.getAdminRole()));
boolean existingAdminAccount = userService.existsByUsername(adminAccount.getUsername());
if (!existingAdminAccount) {
LOG.info("Admin Account created.");
@ -50,7 +51,6 @@ public class Application {
} else {
LOG.info("Admin Account already exists.");
}
};
}
}

View File

@ -1,6 +1,5 @@
package de.arminwolf.configs;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;

View File

@ -3,6 +3,7 @@ package de.arminwolf.configs;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import de.arminwolf.util.RSAKeyProvider;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
@ -13,7 +14,7 @@ import org.springframework.stereotype.Component;
@AllArgsConstructor
@Component
public class TokenProvider {
public class TokenConfig {
private final RSAKeyProvider keyProvider;

View File

@ -1,19 +1,15 @@
package de.arminwolf.configs;
import de.arminwolf.Application;
import de.arminwolf.exceptions.AppException;
import de.arminwolf.models.User;
import de.arminwolf.models.dto.RoleDTO;
import de.arminwolf.models.dto.UserDTO;
import de.arminwolf.services.UserService;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -25,24 +21,19 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Component;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Component
public class UserAuthenticationProvider {
private static Logger LOG = LoggerFactory.getLogger(UserAuthenticationProvider.class);
public static final String ROLES = "roles";
public static final String ROLES = "roles";
public static final String USERNAME = "username";
@Value("${security.jwt.token.issuer:https://armin-wolf.de}")
private String issuer;
private String issuer;
private final PasswordEncoder passwordEncoder;
private final UserService userService;
@ -51,22 +42,20 @@ public class UserAuthenticationProvider {
public Authentication validateToken(final String token) throws MalformedURLException {
Jwt decode = jwtDecoder.decode(token);
boolean equals = decode.getIssuer().toString().equals(issuer);
if (!equals) {
Jwt decode = jwtDecoder.decode(token);
if (!decode.getIssuer().toString().equals(issuer)) {
throw new AppException("Invalid token", HttpStatus.UNAUTHORIZED);
}
String username = decode.getClaim(USERNAME);
User user = userService.findByUsername(username)
User user = userService.findByUsername(decode.getClaim(USERNAME))
.orElseThrow(() -> new AppException("User not found", HttpStatus.BAD_REQUEST));
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
public String createToken(final UserDTO user) {
Date now = new Date();
Date validity = new Date(now.getTime() + 3600000); // 1 hour
Date now = new Date();
Date validity = new Date(now.getTime() + TimeUnit.HOURS.toMillis(1));
JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
.issuer(issuer)
@ -76,6 +65,7 @@ public class UserAuthenticationProvider {
.issuedAt(now.toInstant())
.build();
LOG.info("Creating token for user: {}", user.getUsername());
JwtEncoderParameters jwtEncoderParameters = JwtEncoderParameters.from(jwtClaimsSet);
return jwtEncoder.encode(jwtEncoderParameters).getTokenValue();
}

View File

@ -1,11 +1,6 @@
package de.arminwolf.configs;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import de.arminwolf.util.UserAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -15,11 +10,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@ -34,7 +24,7 @@ import org.springframework.security.web.authentication.www.BasicAuthenticationFi
public class WebConfig {
private final UserAuthenticationEntryPoint userAuthenticationEntryPoint;
private final UserAuthenticationProvider userAuthenticationProvider;
private final UserAuthenticationProvider userAuthenticationProvider;
@ -47,6 +37,10 @@ public class WebConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((requests) -> requests
.requestMatchers(HttpMethod.POST, "/login", "/register").permitAll()
.requestMatchers(HttpMethod.GET, "/public/**").permitAll()
.requestMatchers("/api/**").hasRole("ADMIN")
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
.anyRequest().authenticated());
return http.build();
}

View File

@ -1,15 +1,18 @@
package de.arminwolf.controllers;
import jakarta.annotation.security.RolesAllowed;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/")
public class AdminAccessController {
@GetMapping("/admin")
@RolesAllowed("ADMIN")
@GetMapping("/")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> admin() {
return ResponseEntity.ok("Admin access granted");
}

View File

@ -1,18 +1,17 @@
package de.arminwolf.controllers;
import de.arminwolf.models.dto.LoginRequestDTO;
import de.arminwolf.models.dto.RegisterRequestDTO;
import de.arminwolf.models.dto.RegistrationRequestDTO;
import de.arminwolf.models.dto.UserDTO;
import de.arminwolf.services.AuthenticationService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.net.URISyntaxException;
@AllArgsConstructor
@RestController
@ -20,7 +19,6 @@ public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping("/login")
public ResponseEntity<UserDTO> login(@RequestBody LoginRequestDTO loginRequestDTO) {
return ResponseEntity.ok(authenticationService.login(loginRequestDTO));
@ -28,7 +26,7 @@ public class AuthenticationController {
@PostMapping("/register")
public ResponseEntity<UserDTO> register(@RequestBody RegisterRequestDTO registerRequestDTO) {
public ResponseEntity<UserDTO> register(@RequestBody RegistrationRequestDTO registerRequestDTO) {
UserDTO registeredUser = authenticationService.register(registerRequestDTO);
return ResponseEntity
.created(URI.create("/users/" + registeredUser.getId()))

View File

@ -1,15 +1,17 @@
package de.arminwolf.controllers;
import jakarta.annotation.security.RolesAllowed;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@PreAuthorize("hasAnyRole(['USER', 'ADMIN'])")
@RequestMapping("/user/")
public class UserAccessController {
@GetMapping("/user")
@RolesAllowed({ "USER", "ADMIN" })
@GetMapping("/")
public ResponseEntity<String> user() {
return ResponseEntity.ok("User access granted");
}

View File

@ -1,6 +1,5 @@
package de.arminwolf.exceptions;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.http.HttpStatusCode;

View File

@ -8,20 +8,20 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import lombok.Setter;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import static jakarta.persistence.FetchType.LAZY;
import java.util.stream.Collectors;
@Table(name = "app_users")
@Data
@ -35,12 +35,17 @@ public class User implements UserDetails {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
private String username;
private String password;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
private String email;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@ -51,11 +56,13 @@ public class User implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return getRoles();
public List<SimpleGrantedAuthority> getAuthorities() {
return getRoles()
.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_".concat(role.getName())))
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;

View File

@ -1,12 +1,14 @@
package de.arminwolf.models.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class LoginRequestDTO {
private String username;

View File

@ -1,17 +1,18 @@
package de.arminwolf.models.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RegisterRequestDTO {
public class RegistrationRequestDTO {
private String username;
private String password;
private String email;
private String firstName;
private String lastName;
private String username;
private String email;
private String password;
}

View File

@ -13,11 +13,12 @@ import java.util.Set;
@Builder
public class UserDTO {
private Long id;
private String firstName;
private String lastName;
private String username;
private Long id;
private String firstName;
private String lastName;
private String username;
private String token;
private String email;
private Set<RoleDTO> roles;
}

View File

@ -0,0 +1,27 @@
package de.arminwolf.models.mapper;
import de.arminwolf.models.User;
import de.arminwolf.models.dto.RegistrationRequestDTO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
@Mapper(componentModel = "spring")
public abstract class RegistrationMapper {
@Autowired
protected PasswordEncoder passwordEncoder;
@Mapping(target = "password", qualifiedByName ="passwordMapping")
public abstract User registrationRequestDTOToUser(RegistrationRequestDTO registrationRequestDTO);
@Named("passwordMapping")
String passwordMapping(String password) {
return this.passwordEncoder.encode(password);
}
}

View File

@ -0,0 +1,19 @@
package de.arminwolf.models.mapper;
import de.arminwolf.models.Role;
import de.arminwolf.models.dto.RoleDTO;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.factory.Mappers;
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface RoleMapper {
RoleDTO roleToRoleDTO(Role role);
Role roleDTOToRole(RoleDTO roleDTO);
}

View File

@ -0,0 +1,16 @@
package de.arminwolf.models.mapper;
import de.arminwolf.models.User;
import de.arminwolf.models.dto.UserDTO;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.factory.Mappers;
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {
UserDTO userToUserDTO(User user);
User userDTOToUser(UserDTO userDTO);
}

View File

@ -2,67 +2,57 @@ package de.arminwolf.services;
import de.arminwolf.configs.UserAuthenticationProvider;
import de.arminwolf.exceptions.AppException;
import de.arminwolf.models.Role;
import de.arminwolf.models.User;
import de.arminwolf.models.dto.LoginRequestDTO;
import de.arminwolf.models.dto.RegisterRequestDTO;
import de.arminwolf.models.dto.RoleDTO;
import de.arminwolf.models.dto.RegistrationRequestDTO;
import de.arminwolf.models.dto.UserDTO;
import de.arminwolf.models.mapper.RegistrationMapper;
import de.arminwolf.models.mapper.UserMapper;
import de.arminwolf.repositories.RoleRepository;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service
public class AuthenticationService {
private final UserService userService;
private final UserAuthenticationProvider userAuthenticationProvider;
private final PasswordEncoder passwordEncoder;
private final RoleRepository roleRepository;
private final UserService userService;
private final UserAuthenticationProvider userAuthenticationProvider;
private final PasswordEncoder passwordEncoder;
private final RoleRepository roleRepository;
@Autowired
private RegistrationMapper registrationMapper;
@Autowired
private UserMapper userMapper;
public UserDTO login(LoginRequestDTO loginRequestDTO) {
UserDTO userDTO = userService.login(loginRequestDTO.getUsername(), loginRequestDTO.getPassword());
final String token = userAuthenticationProvider.createToken(userDTO);
userDTO.setToken(token);
userDTO.setToken(userAuthenticationProvider.createToken(userDTO));
return userDTO;
}
public UserDTO register(final RegisterRequestDTO registerRequestDTO) {
public UserDTO register(final RegistrationRequestDTO registerRequestDTO) {
boolean userExists = userService.existsByUsername(registerRequestDTO.getUsername());
if (userExists) {
throw new AppException("User already exists", HttpStatus.BAD_REQUEST);
}
User createdUser = User.builder().email(registerRequestDTO.getEmail())
.firstName(registerRequestDTO.getFirstName())
.lastName(registerRequestDTO.getLastName())
.username(registerRequestDTO.getUsername())
.password(passwordEncoder.encode(registerRequestDTO.getPassword()))
.roles(Set.of(roleRepository.findByName("USER")))
.build();
// save user
User user = registrationMapper.registrationRequestDTOToUser(registerRequestDTO);
user.setRoles(userService.getDefaultRoles());
userService.save(user);
User savedUser = userService.save(createdUser);
Set<Role> roles = savedUser.getRoles();
Set<RoleDTO> roleDTO = roles.stream()
.map(role -> new RoleDTO(role.getId(), role.getName()))
.collect(Collectors.toSet());
UserDTO userDTO = new UserDTO(savedUser.getId(),
savedUser.getFirstName(),
savedUser.getLastName(),
savedUser.getUsername(),
null, roleDTO);
final String token = userAuthenticationProvider.createToken(userDTO);
userDTO.setToken(token);
// convert the latest user to a userDTO
final UserDTO userDTO = userMapper.userToUserDTO(user);
userDTO.setToken(userAuthenticationProvider.createToken(userDTO));
return userDTO;
}
}

View File

@ -7,12 +7,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import static org.springframework.data.util.Optionals.ifPresentOrElse;
@Service
public class RoleService {
@ -24,21 +21,31 @@ public class RoleService {
return new HashSet<>(repository.findAll());
}
@Transactional
public void init() {
public Role getDefaultRole() {
Role user = repository.findByName("USER");
if (Objects.isNull(user)) {
user = Role.builder().name("USER").build();
repository.save(user);
if (Objects.nonNull(user)) {
return user;
} else {
return createRoleByName("USER");
}
}
public Role getAdminRole() {
Role admin = repository.findByName("ADMIN");
if (Objects.isNull(admin)) {
admin = Role.builder().name("ADMIN").build();
repository.save(admin);
if (Objects.nonNull(admin)) {
return admin;
} else {
return createRoleByName("ADMIN");
}
}
@Transactional
private Role createRoleByName(final String name) {
Role role = Role.builder().name(name).build();
repository.save(role);
return role;
}
}

View File

@ -5,14 +5,19 @@ import de.arminwolf.models.Role;
import de.arminwolf.models.User;
import de.arminwolf.models.dto.RoleDTO;
import de.arminwolf.models.dto.UserDTO;
import de.arminwolf.models.mapper.RoleMapper;
import de.arminwolf.models.mapper.UserMapper;
import de.arminwolf.repositories.RoleRepository;
import de.arminwolf.repositories.UserRepository;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.nio.CharBuffer;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -22,8 +27,17 @@ import java.util.stream.Collectors;
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
public User createUser(final String firstName, final String lastName, final String username, final String password, final Set<Role> allRoles) {
return User.builder().id(0L).email("")
@ -45,11 +59,23 @@ public class UserService {
}
public Set<Role> getDefaultRoles() {
Role user = roleRepository.findByName("USER");
if (Objects.nonNull(user)) {
return Set.of(user);
} else {
Role role = Role.builder().name("USER").build();
return Set.of(roleRepository.save(role));
}
}
public UserDTO login(final String username, final String password) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new AppException("Unknown user", HttpStatus.NOT_FOUND));
if (passwordEncoder.matches(CharBuffer.wrap(password), user.getPassword())) {
/*
Set<Role> roles = user.getRoles();
Set<RoleDTO> roleDTO = roles.stream()
.map(role -> new RoleDTO(role.getId(), role.getName()))
@ -60,6 +86,9 @@ public class UserService {
user.getLastName(),
user.getUsername(),
null, roleDTO);
*/
return userMapper.userToUserDTO(user);
}
throw new AppException("Invalid password", HttpStatus.BAD_REQUEST);

View File

@ -1,10 +1,7 @@
package de.arminwolf.configs;
package de.arminwolf.util;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;
import java.security.KeyPair;

View File

@ -1,4 +1,4 @@
package de.arminwolf.configs;
package de.arminwolf.util;
import de.arminwolf.exceptions.AppException;
import de.arminwolf.models.dto.ErrorDTO;

View File

@ -1,4 +1,4 @@
package de.arminwolf.configs;
package de.arminwolf.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.arminwolf.models.dto.ErrorDTO;