Salta el contingut

4. Seguretat

1. HTTPS

Avui en dia, necessitem afegir tot el que puguem per assegurar les nostres aplicacions. Estudiarem el concepte de tokens per autoritzar i autenticar les nostres sol·licituds, però necessitem una capa extra, https.

En aquesta pàgina web https://tiptopsecurity.com/how-does-https-work-rsa-encryption-explained/ podeu trobar com funciona https.

1.1. Certificat

Primerament, necessitem generar certificats, o comprar-los. Utilitzarem l'eina keytool inclosa amb el kit de desenvolupament de Java per generar-los. Aquesta comanda genera un parell de certificats (públic i privat).

Bash
keytool -genkeypair -alias joange -keyalg RSA -keysize 2048
-storetype PKCS12 -keystore jgce.p12 -validity 3650

Després d'executar aquesta comanda, hem de respondre sobre qui som, de la següent manera:

![keytool](./img/keytool.png){width=95%}

1.2. Configurar Spring

Un cop finalitzat el procés, hem d'afegir el certificat dins del nostre projecte. Per exemple, dins de /resources/keystore. Finalment, hem de carregar el certificat i habilitar SSL, simplement afegint aquestes línies a application.properties:

Bash
# The format used for the keystore.
server.ssl.key-store-type=PKCS12
# The path to the keystore containing the certificate
server.ssl.key-store=classpath:keystore/jgce.p12
# The password used to generate the certificate
server.ssl.key-store-password=joangeca
# The alias mapped to the certificate
server.ssl.key-alias=joange

# Use HTTPS instead of HTTP
server.ssl.enabled=true
![HTTPS_Spring_Project](./img/HTTPS_Spring_Project.png){width=95%}

I això és tot, quan Spring comenci veurem que està funcionant amb el protocol https:

Bash
2023-01-13 08:29:37.267  INFO 83291 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 9091 (https)
![HTTPS_Working](./img/HTTPS_Working.png){width=95%}

i en la sol·licitud probablement els navegadors no confiaran en el nostre certificat (haurem d'afegir una excepció de confiança):

![Certificate](./img/Certificate.png){width=95%}

2. Spring Security

Spring Security és un projecte paraigua que agrupa tots els mecanismes referents a la seguretat. Necessitem afegir:

XML
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> 
</dependency>

i automàgicament:

  • La configuració per defecte està habilitada, a través d'un filtre, anomenat SpringSecurityFilterChain.
  • Es crea un bean de tipus UserDetailsService amb un usuari anomenat user i una contrasenya aleatòria que es mostra per la consola.
  • El filtre es registra en el contenidor de servlets per a totes les sol·licituds.

Encara que no has configurat gaire, té moltes conseqüències:

  • Requereix autenticació per interactuar amb la nostra aplicació
  • Genera un formulari de login per defecte.
  • Genera un mecanisme de logout
  • Protegeix l'emmagatzematge de contrasenyes amb BCrypt.
  • Proporciona protecció contra atacs CSRF, Fixació de Sessió, Clickjacking...

Podem crear una classe de configuració, amb aquest contingut:

Java
1
2
3
4
@Configuration
public class SecurityConfig {

}

Atenció

Tornarem a aquesta classe per afegir més configuracions. El més interessant és el mètode configure i la creació de diversos Beans utilitzats per altres classes.

2.1. Dades d'Usuaris per a Autenticació i Autorització (Models)

Important

A partir d'ara, aquest exemple es basa en una pàgina web famosa https://www.bezkoder.com. Expliquem tot el necessari de l'exemple que podeu veure aquí.

En aquesta secció prepararem la nostra aplicació per identificar usuaris amb diversos rols. Amb aquests rols, podrem concedir o no accés a diversos recursos.

2.2. Usuaris & Rols

Per fer això, necessitem crear una classe User, per emmagatzemar aquesta informació a la nostra base de dades. Aquest usuari podria tenir una col·lecció de rols. Podem fer-ho amb una relació de molts a molts.

Java
1
2
3
4
5
public enum ERole {
  ROLE_USER,
  ROLE_MODERATOR,
  ROLE_ADMIN
}

basat en aquesta enumeració, farem una classe Role per emmagatzemar els rols que la nostra aplicació suportarà:

Java
1
2
3
4
5
6
7
@Data
@Entity
@Table(name = "roles")
public class Role {
  private Integer id;
  private ERole name;
}

finalment, una classe User com la següent. Afegim noves anotacions de validació:

Java
@Entity
@Table(name = "users", 
    uniqueConstraints = { 
      @UniqueConstraint(columnNames = "username"),
      @UniqueConstraint(columnNames = "email") 
    })
public class User {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @NotBlank
  @Size(max = 20)
  private String username;

  @NotBlank
  @Size(max = 50)
  @Email
  private String email;

  @NotBlank
  @Size(max = 120)
  private String password;

  @ManyToMany(fetch = FetchType.LAZY)
  @JoinTable( name = "user_roles", 
        joinColumns = @JoinColumn(name = "user_id"), 
        inverseJoinColumns = @JoinColumn(name = "role_id"))
  private Set<Role> roles = new HashSet<>();

  public User() {
  }

  public User(String username, String email, String password) {
    this.username = username;
    this.email = email;
    this.password = password;
  }

}

2.3. Repositori d'Usuaris i Rols

Necessitem crear els nostres repositoris per a les últimes entitats, creant interfícies com normalment fem.

Java
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
  Optional<Role> findByName(ERole name);
}

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findByUsername(String username);

  Boolean existsByUsername(String username);

  Boolean existsByEmail(String email);
}

També afegim mètodes per comprovar l'existència de l'usuari per nom i correu electrònic, i mètodes per trobar per nom, tant User com Role.

2.4. UserDetails

Spring necessita que algú implementi la interfície UserDetails, molt important perquè Spring Security utilitzarà un UserDetails. UserDetails conté la informació necessària per construir un objecte Authentication a partir de DAOs o altres fonts de dades de seguretat. Creem una classe, anomenada UserDetailsImpl, que:

  • Ha de tenir els camps username i password, i els getters. Heu de respectar els noms, qualsevol canvi està prohibit. Aquests mètodes seran utilitzats per les classes d'autenticació.
  • Sobreescriure mètodes de UserDetails, per exemple getAuthorities() i diversos mètodes per controlar si l'usuari està bloquejat, caducat, etc.

Classe completa:

Java
public class UserDetailsImpl implements UserDetails {
  private static final long serialVersionUID = 1L;

  private Long id;
  private String username;
  private String email;

  @JsonIgnore
  private String password;

  private Collection<? extends GrantedAuthority> authorities;

  public UserDetailsImpl(Long id, String username, String email, String password,
      Collection<? extends GrantedAuthority> authorities) {
    this.id = id;
    this.username = username;
    this.email = email;
    this.password = password;
    this.authorities = authorities;
  }

  public static UserDetailsImpl build(User user) {
    List<GrantedAuthority> authorities = user.getRoles().stream()
        .map(role -> new SimpleGrantedAuthority(role.getName().name()))
        .collect(Collectors.toList());

    return new UserDetailsImpl(
        user.getId(), 
        user.getUsername(), 
        user.getEmail(),
        user.getPassword(), 
        authorities);
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }


  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }

}

Tingues en compte que:

  • Utilitzem private Collection<? extends GrantedAuthority> authorities; per emmagatzemar les autoritats (és a dir, rols) en un format que és entès per Spring Security.
  • En lloc de crear un constructor, creem un builder(), que rep un User, extreu la informació d'aquest i transforma la List<Role> en autoritats, i després, el builder crida al constructor.

En lloc de crear un User i Role Service, crearem un UserDetailSeriveImpl (que implementa UserDetailService de Spring), per tal de recuperar un User del repositori, i després retorna un UserDetailImpl, de la següent manera:

Java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
  @Autowired
  UserRepository userRepository;

  @Override
  @Transactional
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userRepository.findByUsername(username)
        .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username));

    return UserDetailsImpl.build(user);
  }

}

2.5. Càrregues de DTO's

En aquesta secció veurem les classes necessàries que emmagatzemen informació per a:

  • Registrar un nou usuari, per rebre informació del client i emmagatzemar un nou usuari. Aquesta classe és SignupRequest.
  • Iniciar sessió d'un usuari, per accedir al nostre sistema. Aquesta classe és LoginRequest.
  • JwtResponse, relacionada com a resposta a la sol·licitud d'inici de sessió. Aquesta resposta contindrà un Token JWT, utilitzat per autoritzar sol·licituds posteriors. Aquesta classe és JwtResponse.

2.6. SignupRequest

Aquesta classe conté informació per registrar un nou usuari. Conté anotacions de validació.

Java
public class SignupRequest {
  @NotBlank
  @Size(min = 3, max = 20)
  private String username;

  @NotBlank
  @Size(max = 50)
  @Email
  private String email;

  private Set<String> role;

  @NotBlank
  @Size(min = 6, max = 40)
  private String password;

  // get and set
Per enviar aquesta informació, l'objecte json rebut serà semblant a:

JSON
1
2
3
4
5
6
{
    "username":"joange",
    "email":"jg.camarenaestruch@edu.gva.es",
    "password":"123456",
    "role":["admin","user"]
}

2.7. LoginRequest

Molt senzilla:

Java
1
2
3
4
5
6
public class LoginRequest {
    @NotBlank
  private String username;

    @NotBlank
    private String password;

i l'objecte json associat serà:

JSON
1
2
3
4
{
    "username":"joange",
    "password":"123456"
}

Atenció

En aquesta classe podem marcar els camps com a obligatoris, amb l'anotació @NotBlank de javax.validation.constraints.NotBlank. Has d'afegir:

XML
1
2
3
4
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

2.8. JwtResponse

Aquesta classe DTO és la classe que es retorna com a sol·licitud de login. Ha de contenir poca informació sobre el nostre usuari i el més important, un token JWT. Aquest token s'utilitzarà per autoritzar-nos, com estudiarem a la següent secció.

Java
1
2
3
4
5
6
7
public class JwtResponse {
  private String token;
  private String type = "Bearer";
  private Long id;
  private String username;
  private String email;
  private List<String> roles;

Tingues en compte que:

  • Els camps d'aquesta classe es poblaran a partir de la classe User, com un DTO.
  • Hem canviat el format del rol, de la classe Role a String, amb una millor gestió en els clients.
  • El String token és on s'emmagatzema el token JWT. De fet, un token és una cadena de text, com mostrarem ara.

3. Tokens JWT

3.1. Què és un token?

JSON Web Tokens (JWT) s'han introduït com un mètode de securitzar la comunicació segura entre dues parts. Es va introduir amb l'especificació RFC 7519 per l'Internet Engineering Task Force (IETF). Tot i que podem utilitzar JWT amb qualsevol tipus de mètode de comunicació, avui en dia, JWT és molt popular per gestionar l'autenticació i l'autorització sobre HTTP.

Primer, necessitaràs conèixer algunes característiques de HTTP:

  • HTTP és un protocol sense estat, el que significa que una sol·licitud HTTP no manté l'estat. El servidor no és conscient de cap sol·licitud anterior enviada pel mateix client.
  • Les sol·licituds HTTP haurien de ser autònomes. Han d'incloure informació sobre sol·licituds anteriors que l'usuari ha fet en la mateixa sol·licitud.

Hi ha algunes maneres de fer això, però la manera més popular és establir un session_id, que és una referència a la informació de l'usuari:

  • El servidor emmagatzemarà aquest ID de sessió en memòria o en una base de dades. El client enviarà cada sol·licitud amb aquest ID de sessió.
  • El servidor pot obtenir informació sobre el client utilitzant aquesta referència.
  • Normalment, aquest ID de sessió s'envia a l'usuari com una cookie.

Aquí tens el diagrama de com funciona l'autenticació basada en sessions.

![authentication-and-authorization-in-expressjs-using-jwt-1](./img/authentication-and-authorization-in-expressjs-using-jwt-1.png){width=95%}

D'altra banda, amb JWT, quan el client envia una sol·licitud d'autenticació al servidor, aquest enviarà un token JSON al client, que inclou tota la informació sobre l'usuari juntament amb la resposta.

El client enviarà aquest token amb totes les sol·licituds posteriors. Per tant, el servidor no haurà d'emmagatzemar cap informació sobre la sessió. Però hi ha un problema amb aquest enfocament. Qualsevol pot enviar una sol·licitud falsa amb un token JSON fals i fer-se passar per algú que no és.

Per exemple, suposem que després de l'autenticació, el servidor retorna un objecte JSON al client amb el nom d'usuari i el temps d'expiració. Així, com que l'objecte JSON és llegible, qualsevol pot editar aquesta informació i enviar una sol·licitud amb ella. El problema és que no hi ha manera de validar aquesta sol·licitud.

Aquí és on entra en joc la signatura testimoni. Així que en lloc d'enviar només un token JSON normal, el servidor enviarà un token signat, que pot verificar que la informació no ha canviat.

![authentication-and-authorization-in-expressjs-using-jwt-2](./img/authentication-and-authorization-in-expressjs-using-jwt-2.png){width=95%}

3.2. Estructura d'un JWT

Parlem de l'estructura d'un JWT a través d'un token d'exemple:

JSON
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Com podeu veure, hi ha tres seccions en aquest JWT, cadascuna separada per un punt.

Nota

La codificació Base64 és una manera d'assegurar que les dades no es corrompin, ja que no comprimeixen ni xifren les dades, sinó que simplement les codifiquen d'una manera que la majoria dels sistemes poden entendre. Podeu llegir qualsevol text codificat en Base64 simplement descodificant-lo.

La primera secció del JWT és un header, que és una cadena codificada en Base64. Si descodifiqueu el header, es veuria així:

JSON
1
2
3
4
{
   "alg":"HS256",
   "typ":"JWT"
}

La secció del header conté l'algoritme de hash, que es va utilitzar per generar la signatura del token i el tipus.

La segona secció és el payload que conté l'objecte JSON que es va enviar de tornada a l'usuari. Com que només està codificat en Base64, qualsevol pot descodificar-lo fàcilment. És obligatori no incloure dades sensibles en els JWT, com ara contrasenyes o informació personal identificable.

Normalment, el cos del JWT es veurà així, tot i que no necessàriament s'aplica:

JSON
1
2
3
4
5
{
   "sub": "1234567890",
   "name": "John Doe",
   "iat": 1516239022
}

Nota

La majoria de les vegades, la propietat sub contindrà l'ID de l'usuari, la propietat iat (issued at), abreujada com emès a, és el segell de temps d'emissió del token. També podeu veure algunes propietats comunes, com ara eat o exp, que és el temps d'expiració del token.

Totes aquestes propietats són els claims del token, la informació.

La secció final és la signatura del token. Aquesta es genera fent un hash de la cadena creada amb les dues seccions anteriors i una contrasenya secreta, utilitzant l'algoritme esmentat a la secció del header.

Podeu visitar https://www.javainuse.com/jwtgenerator i https://jwt.io per generar tokens i provar amb diverses dades, secrets i hashes.

![authentication-and-authorization-in-expressjs-using-jwt-3](./img/authentication-and-authorization-in-expressjs-using-jwt-3.png){width=95%}

3.3. JWT Library class

Afegirem al pom.xml les següents dependències:

XML
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.10.7</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.10.7</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.10.7</version>
  <scope>runtime</scope>
</dependency>

Aquesta classe és la que genera i comprova la integritat dels tokens. Anem a mostrar aquesta classe i quins elements necessita. Aquesta classe serà una classe de biblioteca amb mètodes per crear tokens, validar-los i extreure informació d'ells. Podem trobar aquesta classe amb noms com JWTUtils o JWTTokenProvider. L'esquelet d'aquesta classe és el següent:

  • Càrrega o definició de constants del token
  • Mètode per generar JWT a partir d'un objecte Authentication
  • Mètodes per obtenir informació del token
  • Mètode per validar el token (signatura)
Java
1
2
3
4
5
6
7
8
9
// constants and secrets

  public String generateJwtToken(Authentication authentication);

  public boolean validateJwtToken(String authToken);

  public String getUserNameFromJwtToken(String token);

  // another methods.

3.4. Generar Tokens

Vegem cada mètode. Primer, necessitem un objecte Authentication. Hem de saber que aquest objecte representa un altre token (no el nostre JWT) amb les credencials de l'objecte que volem identificar.

Java
public String generateJwtToken(Authentication authentication) {

    UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();

    return Jwts.builder()
        .setSubject((userPrincipal.getUsername()))
        .setIssuedAt(new Date())
        .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
        .signWith(SignatureAlgorithm.HS512, jwtSecret)
        .compact();
  }

Veiem que:

  • Rebem un objecte Authentication, en el qual hem guardat un UserDetailsImpl. Com totes les implementacions de detalls d'usuari, podem obtenir el nom d'usuari, i l'utilitzem per establir el subjecte del token.
  • Establim IAT al moment actual.
  • Establim el temps d'expiració.
  • Establim l'algoritme de xifrat i la paraula secreta, i el token està llest per...
  • El mètode compact() crea i transforma el token a String.

Nota

El jwtSecret és la contrasenya que necessitem. És una bona idea emmagatzemar-la dins de application.properties i recuperar-la a una variable, com jwtExpiration. Podem utilitzar l'anotació @Value:

Java
1
2
3
4
5
  @Value("${app.jwtSecret}")
  private String jwtSecret;

  @Value("${app.jwtExpirationMs}")
  private int jwtExpirationMs;

3.5. Validació de Tokens

Molt fàcil:

Java
public boolean validateJwtToken(String authToken) {
    try {
      Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
      return true;
    } catch (SignatureException e) {
      logger.error("Invalid JWT signature: {}", e.getMessage());
    } catch (MalformedJwtException e) {
      logger.error("Invalid JWT token: {}", e.getMessage());
    } catch (ExpiredJwtException e) {
      logger.error("JWT token is expired: {}", e.getMessage());
    } catch (UnsupportedJwtException e) {
      logger.error("JWT token is unsupported: {}", e.getMessage());
    } catch (IllegalArgumentException e) {
      logger.error("JWT claims string is empty: {}", e.getMessage());
    }

En aquest mètode comprovem si és un token vàlid. Utilitzem parseClaimsJws(String token), després d'assignar la contrasenya secreta per obtenir els claims. Si hi ha algun problema amb la integritat del token, podria aparèixer una excepció. Obtenim els claims dins d'un bloc try-catch i informem si passa alguna cosa. Retornarem true si no es captura cap excepció.

Nota

Recorda que els claims són el contingut del payload del token. No necessitem els claims en aquest mètode, només comprovar si tot està bé.

4. Authentication Controller

Ara que també sabem com crear tokens i l'estructura de User a la nostra base de dades, és hora d'exposar el nostre camí per registrar i iniciar sessió d'usuaris. Per tant, només són obligatoris dos mètodes. La classe podria ser alguna cosa així:

Java
1
2
3
4
5
6
@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    ...
}
  • L'anotació @CrossOrigin permet sol·licituds de cross-origin en classes de controlador específiques i/o mètodes de controlador. Es processa si es configura un HandlerMapping apropiat. El Cross-Origin Resource Sharing (CORS) és un concepte de seguretat que permet restringir els recursos implementats en navegadors web. Evita que el codi JavaScript produeixi o consumeixi sol·licituds contra un origen diferent. Per exemple, la vostra aplicació web s'està executant al port 8080 i mitjançant JavaScript esteu intentant consumir serveis web RESTful des del port 9090. En aquestes situacions, us trobareu amb el problema de seguretat de Cross-Origin Resource Sharing als vostres navegadors web.
  • @RequestMapping("/api/auth") indica que tots els controladors estan dins del camí /api/auth.

Vegem-los, però abans d'estudiar els mètodes, aquesta classe té aquestes variables necessàries:

  • @Autowired AuthenticationManager authenticationManager; → s'utilitza per crear un token d'Authentication, utilitzat pel context de seguretat de Spring i pel generador de tokens.
  • @Autowired UserRepository userRepository; → s'utilitza per accedir i desar usuaris.
  • @Autowired RoleRepository roleRepository; → per comprovar si els rols que arriben a la sol·licitud són vàlids.
  • @Autowired PasswordEncoder encoder; → s'utilitza per encriptar la contrasenya de l'usuari.
  • @Autowired JwtUtils jwtUtils; → s'utilitza per crear tokens JWT.

Ara que hem presentat els actors, anem a la funció.

4.1. Signup (nou usuari)

El mètode encarregat de crear nous usuaris rebrà un SignupRequest amb aquestes dades:

JSON
1
2
3
4
5
6
{
    "username":"joange",
    "email":"jg.camarenaestruch@edu.gva.es",
    "password":"123456",
    "role":["admin","user"]
}

el cos del mètode és el següent, i vegem-ho per blocs:

Java
@PostMapping("/signup")
  public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
    if (userRepository.existsByUsername(signUpRequest.getUsername())) {
      return ResponseEntity
          .badRequest()
          .body(new MessageResponse("Error: Username is already taken!"));
    }

    if (userRepository.existsByEmail(signUpRequest.getEmail())) {
      return ResponseEntity
          .badRequest()
          .body(new MessageResponse("Error: Email is already in use!"));
    }
    // Create new user's account
    User user = new User(signUpRequest.getUsername(), 
               signUpRequest.getEmail(),
               encoder.encode(signUpRequest.getPassword()));

En aquesta primera part:

  • Comprovem si existeix algun usuari amb el mateix nom d'usuari o correu electrònic, consultant el nostre repositori. Si apareix algun error, retornarem un ResponseEntity com a bad request amb un missatge descriptiu.
  • Finalment, creem un nou usuari amb nom d'usuari, correu electrònic i una contrasenya encriptada.

El següent bloc és responsable d'obtenir els rols (emmagatzemats en un JSONArray de cadenes) i transformar-los en un Set<Role>.

Java
    Set<String> strRoles = signUpRequest.getRole();
    Set<Role> roles = new HashSet<>();

    if (strRoles == null) {
      Role userRole = roleRepository.findByName(ERole.ROLE_USER)
          .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
      roles.add(userRole);
    } else {
      strRoles.forEach(role -> {
        switch (role) {
        case "admin":
          Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN)
              .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
          roles.add(adminRole);

          break;
        case "mod":
          Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR)
              .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
          roles.add(modRole);

          break;
        default:
          Role userRole = roleRepository.findByName(ERole.ROLE_USER)
              .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
          roles.add(userRole);
        }
      });
    }

    user.setRoles(roles);

Vegem-ho:

  • Primer comprovem si el conjunt de rols està buit. Si és cert, establim un nou Role amb ERole.ROLE_USER per defecte.
  • En cas contrari, hem de recórrer tots els rols que obtenim, comprovant cada rol a la base de dades i creant l'objecte Role corresponent.

Finalment, assignem el Set<Role> a l'usuari creat en el primer bloc, i en el tercer bloc només necessitem emmagatzemar el nou usuari amb UserRepository i enviar una resposta d'ok al client.

Java
1
2
3
4
    userRepository.save(user);

    return ResponseEntity.ok(new MessageResponse("User registered successfully!"));
  }

4.2. Signin (Validar-se o accedir)

Nota

En valencià:

  • Signin: registrarse, accedir al login.
  • Signup: inscribirse, donar-se d'alta. La primera vegada

Aquest controlador s'utilitza per iniciar sessió d'un usuari a la nostra aplicació. Com hem estudiat, el DTO que rebem en el HTTP_POST és la classe LoginRequest, com aquesta:

JSON
1
2
3
4
{
    "username":"joange",
    "password":"123456"
}

El mètode encarregat serà:

Java
@PostMapping("/signin")
  public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {

    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));

    SecurityContextHolder.getContext().setAuthentication(authentication);
    String jwt = jwtUtils.generateJwtToken(authentication);

    UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();  

    List<String> roles = userDetails.getAuthorities().stream()
        .map(item -> item.getAuthority())
        .collect(Collectors.toList());

    return ResponseEntity.ok(new JwtResponse(jwt, 
                         userDetails.getId(), 
                         userDetails.getUsername(), 
                         userDetails.getEmail(), 
                         roles));
  }

Tingues en compte:

  • L'anotació @Valid comprova que l'objecte JSON coincideixi amb la classe LoginRequest (mira les anotacions d'aquesta classe).
  • Creem un token d'Authentication (aquest no és el token JWT !!!) amb el nom d'usuari i la contrasenya rebuts. La contrasenya encara no està encriptada.
  • L'últim token d'Authentication es configura al SecurityContextHolder, un objecte que conté un mínim de seguretat a nivell de fil. En aquest pas és quan el subsistema de seguretat de Spring funciona, demanant a UserDetailService un usuari amb aquest nom d'usuari i contrasenya a la nostra base de dades, i es guarda en un Bean UserDetails a la memòria. Si alguna credencial és incorrecta, es llançarà una excepció, que serà gestionada pel nostre sistema (ho estudiarem més endavant).
  • Amb aquest token (autenticació) generem el nostre token. Recorda que dins de jwtUtils, només obtenim el nom d'usuari per generar el subjecte del nostre token.
  • Obtenim l'objecte UserDetails amb el mètode getPrincipal(), i
  • Transformem la llista d'autoritats en una llista de cadenes (has estudiat com mapar llistes? I filtrar? I reduir?).
  • Finalment, creem i retornem un ResponseEntity, amb l'http_Status ok, amb un JwtResponse dins. Token més atributs. Un exemple de JWTrespone és:
JSON
{
    "id": 1,
    "username": "joange",
    "email": "joange.sales@edu.gva.es",
    "roles": [
        "ROLE_USER",
        "ROLE_ADMIN"
    ],
    "accessToken": "eyJhbGciOiJIUzUxMiJ9.
    eyJzdWIiOiJqb2FuZ2UiLCJpYXQiOjE2NzM2MjY1OTYsImV4cCI6MTY3MzcxMjk5Nn0.
    6yMcAYYvHsQ4XKmmT6tr0PmkpJKfPusxnMVHDmIl4WJQ_KtaY08vbt27KdvJHkWCZPO
    4dA2a2HtnAq13vMKAPw",
    "tokenType": "Bearer"
}

Atenció

L'últim token té salts de línia addicionals per evitar sortir del marge del paper, és una cadena completa.

Si el nom d'usuari no es troba a la base de dades, la nostra aplicació crea la següent resposta:

JSON
1
2
3
4
5
6
{
    "path": "/api/auth/signin",
    "error": "Unauthorized",
    "message": "Bad credentials",
    "status": 401
}

5. Tokens en funcionament

Ara que el registre d'usuaris i l'inici de sessió estan implementats, fem una pregunta, Què fa l'aplicació client amb el JWTResponse que ha rebut després del procés d'inici de sessió? Les dades de l'usuari normalment s'utilitzen per a la interfície (nom complet, avatar, etc.). Però què passa amb els tokens?

La resposta, com estudiarem més endavant, és emmagatzemar-lo, i després enviar-lo al servidor en cada sol·licitud com a mètode d'Autenticació i Autorització.

5.1. Enviant tokens

Per enviar un token, necessitem adjuntar-lo a la secció Header, creant un paràmetre Authorization amb el valor Bearer token_recieved, com podeu veure en aquesta captura de pantalla de Postman:

![Send_JWT](./img/Send_JWT.png){width=95%}

Important

Recorda: la paraula Bearer més un espai en blanc més tot el token rebut (com a String)

D'acord, així doncs, l'aplicació client ens enviarà el token a través de la sol·licitud, però, com fa el nostre servidor per obtenir i comprovar el token? La resposta és que hem de dir-ho en forma de filtre.

6. Configuració de Seguretat

La classe que configura la seguretat és WebSecurityConfig. Anem a explicar-la per blocs de nou. Aquesta classe està composta per un conjunt de Beans que s'utilitzaran en tot el projecte (recorda la injecció de codi). Explicarem només el necessari.

Java
1
2
3
4
5
6
@Configuration
@EnableGlobalMethodSecurity(
    prePostEnabled = true)
public class WebSecurityConfig { 

}

Aquesta anotació @Configuration indica a Spring que ha de carregar aquesta classe. També permet que Spring utilitzi anotacions de filtres pre i post (ho estudiarem més endavant).

Java
  @Autowired
  UserDetailsServiceImpl userDetailsService;

  @Autowired
  private AuthEntryPointJwt unauthorizedHandler;

  @Bean
  public AuthTokenFilter authenticationJwtTokenFilter() {
    return new AuthTokenFilter();
  }

Aquests beans s'explicaran més endavant. En poques paraules, són responsables de gestionar errors com a manejador d'excepcions i de com aplicar cadenes de filtres per autenticar usuaris.

Java
1
2
3
4
5
6
7
8
9
  @Bean
  public DaoAuthenticationProvider authenticationProvider() {
      DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();

      authProvider.setUserDetailsService(userDetailsService);
      authProvider.setPasswordEncoder(passwordEncoder());

      return authProvider;
  }

Aquest Bean utilitza el UserDetailService i el PasswordEncoder per fer el procés d'autenticació. Això significa accedir a la base de dades i comprovar l'usuari i la contrasenya (amb el mateix encoder que utilitzem per emmagatzemar usuaris). Els següents Beans creen l'AuthenticationManager i l'encoder.

Java
1
2
3
4
5
6
7
8
9
  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

I finalment, una de les configuracions més importants (i difícils d'entendre), la cadena de filtres de seguretat. Les cadenes de filtres són codi que posem al mig entre el client i el servidor. Aquests filtres intercepten la sol·licitud, l'analitzen i després, depenent del resultat del filtre, simplement passen el control al servidor o envien una resposta al client. Aquest filtre podria canviar la sol·licitud, afegint o eliminant informació que serà utilitzada pel servidor.

Java
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors().and().csrf().disable()
        .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        .authorizeRequests().antMatchers("/api/auth/**").permitAll()
        .antMatchers("/api/test/**").permitAll()
        .anyRequest().authenticated();

    http.authenticationProvider(authenticationProvider());

    http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }

Tingueu en compte que:

  • Habilitem CORS (Cross-Origin Requests) i deshabilitem CSRF (Cross Site Request Forgery)
  • Establim l'authenticationEntryPoint
  • Permetem l'accés a tots els camins a /api/auth/**
  • Permetem l'accés a tots els camins a /api/test/**
  • Qualsevol altra sol·licitud necessitarà ser autenticada

Finalment, afegim el filtre abans i el proveïdor d'autenticació.

En una visió relaxada, per entendre-ho, els filtres es combinen amb anotacions que indiquen on i quan hem de comprovar l'Autenticació i l'Autorització.

![FilterChain](./img/FilterChain.png){width=95%}

6.1. AuthTokenFilter

Aquesta classe conté el procés del token rebut dels clients. Ha d'implementar OncePerRequestFilter i sobreescriure doFilterInternal().

Java
@Override
  protected void doFilterInternal(
    HttpServletRequest request, 
    HttpServletResponse response, 
    FilterChain filterChain) throws ServletException, IOException {
    try {
      String jwt = parseJwt(request);
      if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
        String username = jwtUtils.getUserNameFromJwtToken(jwt);

        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(
                userDetails,
                null,
                userDetails.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    } catch (Exception e) {
      logger.error("Cannot set user authentication: {}", e);
    }

    filterChain.doFilter(request, response);
  }

En paraules nostres, aquest mètode s'executarà quan es faci una sol·licitud i:

  • Extreu el token de la sol·licitud, retornant només la informació rellevant (elimina Bearer).
  • Comprova que el token sigui vàlid, i:
  • Extreu el nom d'usuari (està en el payload del token) i
  • Obté aquest UserDetails i crea un UsernamePasswordAuthenticationToken per ser injectat a la resta de la sol·licitud-resposta, òbviament amb autoritats.
  • Finalment, continua amb el següent filtre, si existeix, cridant filterChain.doFilter(req,res). Si no hi ha cap filtre, el dispatcher passa el control al controlador sol·licitat.

6.2. AuthEntryPoint

Aquesta classe conté codi que s'executarà quan aparegui una excepció. Aleshores, crea un cos genèric dins de la resposta i estableix una resposta precisa per al client.

7. Controller Authorization

Només ens queda dir quina sol·licitud necessita ser autoritzada. Recorda que amb altres classes preparem l'autenticació, Qui ets?. Ara necessitem preguntar Què pots fer?

Com marquem en la nostra filterChain, afegim addFilterBefore per comprovar els rols. Però, on hem de dir els rols que capturen cada sol·licitud? La resposta és fàcil: els controladors. Vegem un controlador de prova amb filtre d'autorització:

Java
@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/test")
public class TestController {
  @GetMapping("/all")
  public String allAccess() {
    return "Public Content.";
  }

  @GetMapping("/user")
  @PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')")
  public String userAccess() {
    return "User Content.";
  }

  @GetMapping("/mod")
  @PreAuthorize("hasRole('MODERATOR')")
  public String moderatorAccess() {
    return "Moderator Board.";
  }

  @GetMapping("/admin")
  @PreAuthorize("hasRole('ADMIN')")
  public String adminAccess() {
    return "Admin Board.";
  }
}
Si:

  • No apareix cap anotació: tots els rols poden fer aquesta sol·licitud
  • @PreAuthorize("hasRole('role')") → Aquesta anotació indica el rol que està autoritzat per fer aquesta sol·licitud. Pots combinar més d'un rol amb or

8. Exercici. Com fer servir aquest projecte?

Probablement tindràs ja una API implementada i funcionant de manera no segura. Ara tens la tasca de fusionar amb aquest projecte per tal d'autoritzar certes operacions sols a usuaris registrats. És una tasca llarga (que no complicada), però el resultat final serà molt gratificant.