Registration Backend
First, let's start with saving the users.
Repository
https://github.com/Mehdi-HAFID/registration
Architecture
Start up a MySQL database. We use version 8. schema called identity_hub
.
Entities
We have two entities that we will save.
User: A simple entity with email
and password
fields. The password
will hold the hash of the password.
The enabled
field is related to Spring Security.
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
@Email
private String email;
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "USER_AUTHORITIES", joinColumns = @JoinColumn(name = "USER_ID"),
inverseJoinColumns = @JoinColumn(name = "AUTHORITY_ID"))
private List<Authority> authorities = new ArrayList<>();
private boolean enabled;
}
Authority: lists all authorities
for the user. At startup, we persisted all possible authorities.
When a user is created, the authorities it has are persisted in a join table.
@Entity
@Table
public class Authority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
Each entity has its own JpaRepository.
Password Encoder
Nidam supports these hashing algorithms: scrypt, bcrypt, argon2, pbkdf2.
custom.password.encoders={'scrypt', 'bcrypt', 'argon2', 'pbkdf2'}
The first algorithm in the list is selected as the _defaultç. So in this case, passwords are encoded with SCrypt. example:
{scrypt}$100801$2umc2uH6XR0Cq9Odmfl5WA==$9Dgl0ZRoCisqnx5C80STGUZH2gmJe8/gHHuL9pTTmzg=
Support for hashes without an ID is supported with the use of this property:
custom.password.idless.encoder="scrypt"
for example this hash: $100801$2umc2uH6XR0Cq9Odmfl5WA==$9Dgl0ZRoCisqnx5C80STGUZH2gmJe8/gHHuL9pTTmzg=
is missing the id \{ID\}
.
so Nidam will use the algorithm specified in custom.password.idless.encoder
which is SCrypt by default.
If you want to make changes to change the default algorithm to BCrypt you simply change the order so that bcrypt is the first in the list.
The same goes for idless hashes you simply change the value to the desired algorithm, for example: custom.password.idless.encoder="bcrypt"
@Bean
public PasswordEncoder passwordEncoder(@Value("#{${custom.password.encoders}}") List<String> encoders,
@Value("#{${custom.password.idless.encoder}}") String idlessEncoderName) {
Map<String, PasswordEncoder> encodersMapping = new HashMap<>();
if(encoders.contains("scrypt")){ encodersMapping.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());}
if(encoders.contains("bcrypt")){ encodersMapping.put("bcrypt", new BCryptPasswordEncoder());}
if(encoders.contains("argon2")){ encodersMapping.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());}
if(encoders.contains("pbkdf2")){ encodersMapping.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());}
// first in list used to encode
DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(encoders.get(0), encodersMapping);
// use this encoder if {id} does not exist
passwordEncoder.setDefaultPasswordEncoderForMatches(getEncoderForIdlessHash(idlessEncoderName));
return passwordEncoder;
}
Authorities
Nidam assumes that the created user has full access.
By default, when the user registers, we attach all the authorities listed in the property custom.authorities={'manage-users','manage-projects'}
.
List all authorities of your application in this property.
For user management, meaning creating users with only a subset of the authorities by a logged-in user, you will have to implement that yourself.
When you read the source code, you will find that registration supports Roles
in addition to Authorities
. But roles support is not fully implemented
just yet in other microservices. So just ignore Roles support for now. When the feature is implemented the documentation will be updated
to reflect that.
UserService
The save()
method receives the DTO with email and password properties and does the following:
- Check if an account with the same email already exists.
- Convert the DTO to the entity object.
- Validate and encode the password.
- Set authorities.
- Return the created entity as a DTO.
public UserRegisteredDto save(UserRegistrationDto userDto){
if(userRepository.findUserByEmail(userDto.getEmail()).isPresent()){
throw new AlreadyExistException(format("Email %s already used!", userDto.getEmail()));
}
User user = registrationMapper.toEntity(userDto);
setPassword(user);
setAuthorities(user);
user.setEnabled(true);
user = userRepository.save(user);
UserRegisteredDto dto = registeredMapper.toDto(user);
return dto;
}
Rest Endpoint
There are two endpoints for registering the user. One without ReCaptcha support and one with. The next section will explain all ReCaptcha related code.
@PostMapping("/register")
public ResponseEntity<UserRegisteredDto> register(@RequestBody UserRegistrationDto userDto){
UserRegisteredDto entity = userService.save(userDto);
return ResponseEntity.status(HttpStatus.CREATED).body(entity);
}
This endpoint can be used with utilities like Postman in development
. In production
, the ReCaptcha endpoint is used.
Google ReCaptcha support
Nidam is meant to be used in production, so support for Google ReCaptcha is implemented using version 3.
The front is meant to send the ReCaptcha key
alongside email and password values.
public class UserRegistrationCaptchaDto {
private String email;
private String password;
private String recaptchaKey;
}
Which the RecaptchaService
validates before continuing the same process described above if the check is successful.
The Endpoint
@PostMapping("/registerCaptcha")
public ResponseEntity<UserRegisteredDto> register(@RequestBody UserRegistrationCaptchaDto userDto){}
The save()
method in UserService is overloaded. The method first checks with Google Recaptcha backend if the request is valid.
public UserRegisteredDto save(UserRegistrationCaptchaDto userDto){
boolean result = recaptchaService.validateCaptcha(userDto.getRecaptchaKey());
if(!result){
throw new ReCaptchaException("Captcha Error");
}
return save(userRegistrationCaptchaMapper.toEntity(userDto));
}
@Service
public class RecaptchaService {
@Value("#{${custom.recaptcha.secret}}")
private String recaptchaSecret;
private ReCaptchaProxy reCaptchaProxy;
public boolean validateCaptcha(String key){
MultiValueMap<String, String> requestMap = new LinkedMultiValueMap<>();
requestMap.add("secret", recaptchaSecret);
requestMap.add("response", key);
CaptchaResponse captchaResponse = reCaptchaProxy.validateReCaptcha(requestMap);
if(captchaResponse == null || captchaResponse.getSuccess() == null){
return false;
}
return captchaResponse.getSuccess();
}
}
validateCaptcha()
method uses Spring OpenFeign to send requests to Google ReCaptcha backend.
RestTemplate has been put in maintenance mode starting with Spring 5.
custom.recaptcha.secret
is the private key
provided to you by ReCaptcha. You can use your own or use the one that comes with this backend for development.
The frontend on the next page uses the public key
of this secret key, so if you use your own make sure to update the frontend key also.
@FeignClient(name = "recaptcha", url = "https://www.google.com/recaptcha/api/siteverify")
public interface ReCaptchaProxy {
@PostMapping
public CaptchaResponse validateReCaptcha(@RequestBody MultiValueMap<String, String> requestMap);
}
In the RecaptchaService.validateCaptcha()
method, you implement your custom recaptcha V3 code. Nidam simply relies on the boolean in the response.
So CaptchaResponse is simply a boolean holder.
If the ReCaptcha check succeeds, then the previous non-recaptcha method is called UserService.save(UserRegistrationDto userDto)
.
Rest of the Configuration
Finally, we have to specify SecurityFilterChain
Bean.
@Configuration
@EnableFeignClients(basePackages = "nidam.registration.proxy") // #1
public class ProjectConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll(). // #2
requestMatchers(HttpMethod.POST,"/register", "/registerCaptcha").permitAll(). // #3
anyRequest().denyAll()
);
http.csrf((csrf) -> csrf.disable()); // #4
http.httpBasic(httpSecurityHttpBasicConfigurer -> httpSecurityHttpBasicConfigurer.disable()); // #5
http.cors(c -> {
CorsConfigurationSource source = request -> { // #6
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:4001", "http://127.0.0.1:4001",
"http://localhost:7080", "http://127.0.0.1:7080"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
return config;
};
c.configurationSource(source);
});
return http.build();
}
}
- We enable OpenFeign for the
proxy
package. - Spring has a weird behavior where when the code throws an exception, it converts it to Access Denied. See Stackoverflow & docs
- We permit access to the two endpoints with the POST method and deny everything else.
- Disable CSRF since we're using different origins.
- Disable Spring Boot autoconfiguration of basic authentication
- CORS configuration: the front is hosted at port 4001 -explained on the next page-. Allows all methods and headers (for CORS preflight requests)
tip
The finished application will be accessed through a reverse proxy at a different port
7080
. But for now, we will simply access the front through its port. Don't let this bother you for now.
First Time Run
The first time you run the registration backend you will have to uncomment two portions of code so that database tables are generated.
spring.sql.init.mode=always
spring.jpa.generate-ddl=true
And
@Value("#{${custom.authorities}}")
private List<String> authorities;
@Bean
ApplicationRunner configureRepository(AuthorityRepository authorityRepository) {
return args -> {
for (String authority : authorities) {
Authority auth = new Authority(authority);
authorityRepository.save(auth);
}
};
}
You must change to your MySQL credentials in application.properties
. Defaults are listed below
spring.datasource.url=jdbc:mysql://localhost:3306/identity_hub
spring.datasource.username=root
spring.datasource.password=CcmN*`6@3T9H%P#yg^V<7v
Run the application using: mvn spring-boot:run
. Check that the tables are created in the schema.
Create The First Account
Using Postman or a similar tool, we will create our first account using the non-recaptcha endpoint.
Result:
{
"id": 27,
"email": "mehdi@nidam.com",
"authorities": [
"manage-users",
"manage-projects"
]
}
Other Info
You're supposed to download the code and read through it since everyone will customize it differently.
You want to take a look at pom.xml
and application.properties
first.
MongoDB Support
MongoDB support is planned for future versions.
This concludes the registration backend.