Date de première publication : 2019/11/07
L'objectif de ce TP est de tester la sécurité basique sur une application SpringBoot :
- un mécanisme d'authentification
- un passage de http au https
Spring Security implémente la sécurité avec des filtres qui s'intercalent entre le client et la servlets. Cela va bien avec le concept d'AOP, ou programmation orientée aspects où la sécurité est justement un aspect (transverse) de l'application
On a les concepts d'authentication (qui es-tu ?), d'authorization (que peux-tu faire ?) et de Principal (l'utilisateur connecté)
- Securing a web app
- https://developer.okta.com/blog/2019/06/20/spring-preauthorize
- https://www.codejava.net/frameworks/spring-boot/spring-boot-security-role-based-authorization-tutorial
- https://devstory.net/11867/configurer-spring-boot-pour-rediriger-http-vers-https
Le sujet a été mis à jour pour utiliser la version 3 de Spring Boot
Mise en place de l'application
On va continuer sur l'application "TOUDOU". On suppose que vous avez une page qui liste toutes les tâches en les affichant dans des posts-its et une page qui permet d'ajouter une nouvelle tâche/post-it.
Pour mettre en place la sécurité, il suffit d'ajouter les deux dépendances suivantes dans le fichier build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
}
/* tasks.withType(JavaCompile) {
options.compilerArgs << '-Xlint:unchecked'
options.deprecation = true
} */
Si vous relancez l'application, elle est maintenant "sécurisée". La console affichera quelque chose comme :
Using generated security password: 6a474bb1-9324-43c0-9e1c-3dd50daa0db8
Mais surtout, il apparait maintenant une page de demande d'authentication. Pour voir ce que cela donne, il suffit de saisir comme identifiant "user" et le mot de passe affiché dans la console ou bien de donner un nom/mot de passe dans le fichier application.properties
spring.security.user.name = loic
spring.security.user.password = loic
Pour se déconnecter, l'URL en /logout
est configurée pour que cela marche.
Pour que la sécurité s'applique ou non sur des pages, il faut utiliser un filtre comme cela :
package app;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/","/home").permitAll()
.anyRequest().authenticated()
)
.formLogin((form) -> form
// .loginPage("/login")
.permitAll()
)
.logout((logout) -> logout.permitAll());
return http.build();
}
// Si vous décommentez ce code, l'utilisateur qui peut se connecter est celui qui est spécifié
// cela désactive l'utilisateur du fichier properties
// ne pas décommenter pour utiliser une base
/*
@Bean
public UserDetailsService userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
*/
}
Vous pouvez maintenant vérifier que les URL /
et /tasks/
sont toujours en accès libre mais que toutes les autres URLs et en particulier /tasks/new
ne sont accessibles qu'après authentification.
Si vous voulez votre propre formulaire de connexion : décommentez .loginPage("/login")
dans le code précédent,
ajoutez ce qu'il faut dans le contrôleur pour le routage et surtout implémentez un formulaire, par exemple :
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" >
<head>
<title>connexion </title>
</head>
<body>
<div th:if="${param.error}">
Mot de passe ou identifiant invalide
</div>
<div th:if="${param.logout}">
Vous avez été déconnecté.
</div>
<form th:action="@{/login}" method="post">
<div><label> Identifiant : <input type="text" name="username"/> </label></div>
<div><label> Mot de passe : <input type="password" name="password"/> </label></div>
<div><input type="submit" value="Connexion"/></div>
</form>
</body>
</html>
Base d'utilisateurs
Pour disposer d'une base d'utilisateurs, il faut ... une base que l'on peut créer, manipuler et lire comme dans le TP précédent.
Pour représenter les utilisateurs, Spring Security offre deux possibilités :
- Uitiliser ou étendre
org.springframework.security.core.userdetails.User
- Implémenter
import org.springframework.security.core.userdetails.UserDetails;
C'est cette dernière méthode que nous retenons car nous voulons créer une entité de type User
:
package app.entities;
import java.io.Serializable;
import java.util.Collection;
// import lombok.Getter
// import lombok.Setter;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.GrantedAuthority;
@Entity
// Renommer est important
@Table(name="users")
public class User implements Serializable, UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
private String password;
public User() {
}
public User(String username, String password) {
this.username = username;
this.password = password;
}
public boolean isEnabled() { return true; }
public boolean isCredentialsNonExpired() { return true; }
public boolean isAccountNonLocked() { return true; }
public boolean isAccountNonExpired() { return true; }
public Collection< ? extends GrantedAuthority> getAuthorities() { return null; }
}
Vous pouvez avoir une erreur de compilation JAVA si vous oubliez une méthode par exemple :-)
Il est important de renommer la table des utilisateurs car "USER" est souvent un mot-clé pour les SGBDs.
Il faut ensuite définir le repository :
package app.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}
On termine par la classe :
package app;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
return user;
}
}
Il ne faut pas oublier de commenter et décommenter les lignes signalées dans la classe WebSecurityConfig
Si vous avez l'erreur suivante :
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
C'est que vous utilisez une base qui ne spécifie pas les mots de passe correctement, ce que n'accepte plus la version de Spring Security 5. Il faut alors encrypter le mot de passe et spécifier l'encryptage utilisé, comme par exemple :
// import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
String mdpe = new BCryptPasswordEncoder().encode("master");
User u = new User("master", "{bcrypt}"+mdpe);
Si vous insérez le code suivant dans hello, vous pouvez avoir le nom de la personne connectée :
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
Pour la dernière phase, il faut découvrir le champ "message" de User
(une chaine de caractères) et l'afficher dans hello.html
Ajouter un tel champ à la classe User
. "message" est le nom de la colonne associée au champ.
Pour afficher ce message, le mieux est de créer une classe spécifique qui lira le champ avec le bout de code suivant et le passera en paramètre à la page html.
User user = (User)
org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Gestion plus fine : les rôles
Il est possible d'utiliser des rôles pour accéder à certaines fonctionnalités, cela peut se faire par annotation au niveau des méthodes (vues incluses) et au niveau des classes : @Secured, @PreAuthorize, @PostAuthorize, @PreFilter, @PostFiler
. Cette gestion intervient APRÈS les opérations effectuées par l'objet de type HttpSecurity
Il est même possible de créer ses propres annotations pour la protection d'éléments qui reviennent souvent :
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('USER')")
public @interface IsAccepted {
}
@IsAccepted
public String methodeAProteger() {
//...
}
En passant, vous pouvez voir comment fonctionne l'annotation @PreAuthorize
Pour pouvoir utiliser ces annotations, il va falloir faire un peu de configuration, par exemple :
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// ...
}
Il faut maintenant associer les rôles avec les utilisateurs.
On peut utiliser JPA pour ce faire. Pour la liste des rôles, c'est facile :
@Entity
@Table(name = "roles")
public class Role {
@Id
@Column(name = "role_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter @Setter
private Integer id;
@Getter @Setter
private String name;
public Role() {
}
}
Je vous laisse écrire le RoleRepository
...
Pour la table qui lie les rôles et les utilisateurs - une jointure - cela va se traduire par un attribut dans la classe User
@ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER)
@JoinTable(
name = "users_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
Il faut également redéfinir la méthode getAuthorities()
de l'interface UserDetails
qui se trouve - pour cet exercice - dans la classe User
:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<Role> roles = getRoles();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
Il ne reste plus qu'à créer, par JPA, des rôles et des utilisateurs avec des rôles pour tester par exemple un utilisateur avec le rôle ADMIN
ou USER
Il existe également les concepts d'Authority
(grain plus fin que le rôle) et de groupe d'utilisateurs.