feat: add authentication and authorization

issue #43
This commit is contained in:
Ricardo Campos
2024-09-05 17:27:34 -03:00
parent e44b2ba82e
commit 48d2a728fc
21 changed files with 648 additions and 19 deletions
+1 -7
View File
@@ -3,8 +3,6 @@
services:
client:
container_name: tasknote
profiles:
- "dev"
image: node:20.17-bullseye-slim
ports:
- "5000:5000"
@@ -20,8 +18,6 @@ services:
java-api:
container_name: java-api
profiles:
- "dev"
depends_on:
postgres:
condition: service_started
@@ -49,8 +45,6 @@ services:
postgres:
container_name: postgres
profiles:
- "dev"
image: postgres:15.8-bookworm
environment:
POSTGRES_DB: tasknote
@@ -59,7 +53,7 @@ services:
POSTGRES_PASSWORD: default
POSTGRES_PORT: 5432
volumes:
- "./localpgdata:/pgdata"
- "/pgdata"
ports:
- "5432:5432"
healthcheck:
+19
View File
@@ -102,6 +102,25 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<!-- Authentication -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
@@ -3,11 +3,16 @@ package br.com.tasknoteapp.java_api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Entrypoint of the Java API service application. */
@SpringBootApplication
public class JavaApiApplication {
public static void main(String[] args) {
SpringApplication.run(JavaApiApplication.class, args);
}
/**
* Main method of the application.
*
* @param args Additional arguments, if any.
*/
public static void main(String[] args) {
SpringApplication.run(JavaApiApplication.class, args);
}
}
@@ -0,0 +1,3 @@
package br.com.tasknoteapp.java_api.auth;
public record JwtAuthenticationResponse(String token) {}
@@ -0,0 +1,11 @@
package br.com.tasknoteapp.java_api.auth;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
/** This record represents a login request with user email and password. */
@Schema(description = "Login request with user email and password.")
public record LoginRequest(
@Schema(description = "User email.") @Email @NotNull String email,
@Schema(description = "User password.") @NotNull String password) {}
@@ -1,22 +1,35 @@
package br.com.tasknoteapp.java_api.config;
import br.com.tasknoteapp.java_api.filter.JwtAuthenticationFilter;
import br.com.tasknoteapp.java_api.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/** This class contains security configurations. */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* Filters a request to add security checks and configurations.
*
@@ -27,18 +40,44 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(custom -> custom.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
custom ->
custom
request ->
request
.requestMatchers("/auth/**")
.permitAll()
.requestMatchers("/rest/**")
.authenticated()
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.anyRequest()
.permitAll())
.sessionManagement(
manager -> manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable);
.formLogin(AbstractHttpConfigurer::disable)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userService.userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
@@ -55,6 +55,6 @@ public class SwaggerConfig {
openApi.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
openApi.setComponents(components);
return new OpenAPI().info(info);
return openApi;
}
}
@@ -0,0 +1,68 @@
package br.com.tasknoteapp.java_api.controller;
import br.com.tasknoteapp.java_api.auth.JwtAuthenticationResponse;
import br.com.tasknoteapp.java_api.auth.LoginRequest;
import br.com.tasknoteapp.java_api.exception.UserAlreadyExistsException;
import br.com.tasknoteapp.java_api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth")
@Tag(name = "Authentication", description = "Authentication controller.")
@AllArgsConstructor
public class AuthenticationController {
private final AuthService authService;
/**
* Authenticate a user given his email and password.
*
* @param loginRequest User data containing email and password.
* @return OK if authenticated, 401 - Unauthorized otherwise
*/
@PostMapping(path = "/signin", consumes = "application/json", produces = "application/json")
@Operation(
summary = "SigIn an existing user",
description = "SigIn an existing user given his email and password",
responses = {
@ApiResponse(responseCode = "200", description = "User successfully logged in"),
@ApiResponse(responseCode = "400", description = "Wrong or missing information"),
})
public JwtAuthenticationResponse signin(@RequestBody @Valid LoginRequest loginRequest) {
String token = authService.signin(loginRequest);
return new JwtAuthenticationResponse(token);
}
/**
* Signup a new user.
*
* @param loginRequest User data with email and password.
* @return JwtAuthenticationResponse containing user token
* @throws UserAlreadyExistsException
*/
@PutMapping(path = "/signup", consumes = "application/json", produces = "application/json")
@Operation(
summary = "Signup a new user",
description = "Signup a new user given his email and password",
responses = {
@ApiResponse(responseCode = "201", description = "User successfully created and saved"),
@ApiResponse(responseCode = "400", description = "Wrong or missing information"),
@ApiResponse(responseCode = "409", description = "User already exists")
})
public ResponseEntity<JwtAuthenticationResponse> signup(
@RequestBody @Valid LoginRequest loginRequest) {
String token = authService.create(loginRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(new JwtAuthenticationResponse(token));
}
}
@@ -0,0 +1,15 @@
package br.com.tasknoteapp.java_api.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/rest/some")
public class SomeController {
@GetMapping
public String some() {
return "Some";
}
}
@@ -0,0 +1,71 @@
package br.com.tasknoteapp.java_api.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
/** This class represents a User in the database. */
@Data
@Entity
@Table(name = "users")
public class UserEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private Boolean admin;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "inactivated_at", nullable = true)
private LocalDateTime inactivatedAt;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getUsername() {
// email in our case
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@@ -0,0 +1,13 @@
package br.com.tasknoteapp.java_api.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
@ResponseStatus(code = HttpStatus.CONFLICT)
public class UserAlreadyExistsException extends ResponseStatusException {
public UserAlreadyExistsException() {
super(HttpStatus.CONFLICT, "User already exists");
}
}
@@ -0,0 +1,13 @@
package br.com.tasknoteapp.java_api.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
@ResponseStatus(code = HttpStatus.NOT_FOUND)
public class UserNotFoundException extends ResponseStatusException {
public UserNotFoundException() {
super(HttpStatus.NOT_FOUND, "User not found");
}
}
@@ -0,0 +1,74 @@
package br.com.tasknoteapp.java_api.filter;
import br.com.tasknoteapp.java_api.service.JwtService;
import br.com.tasknoteapp.java_api.service.UserService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired private UserService userService;
@Autowired private JwtService jwtService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
log.info("-1-authorizationHeader {}", authorizationHeader);
if (Objects.isNull(authorizationHeader) || authorizationHeader.isBlank()) {
log.info("-2-authorizationHeader {}", authorizationHeader);
filterChain.doFilter(request, response);
return;
}
log.info("-3-authorizationHeader {}", authorizationHeader);
String jwtToken = authorizationHeader.substring(7);
String email = jwtService.getEmailFromToken(jwtToken);
log.info("-4-email {}", email);
if (!Objects.isNull(email)
&& Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) {
log.info("-5-email {}", email);
UserDetails user = userService.userDetailsService().loadUserByUsername(email);
if (jwtService.validateTokenAndUser(jwtToken, user)) {
log.info("-6-jwtToken {}", jwtToken);
SecurityContext context = SecurityContextHolder.createEmptyContext();
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user.getUsername(), user.getPassword(), user.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
context.setAuthentication(authToken);
SecurityContextHolder.setContext(context);
}
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,11 @@
package br.com.tasknoteapp.java_api.repository;
import br.com.tasknoteapp.java_api.entity.UserEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** This interface contains methods to access the user table in the database. */
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByEmail(String email);
}
@@ -0,0 +1,41 @@
package br.com.tasknoteapp.java_api.service;
import br.com.tasknoteapp.java_api.auth.LoginRequest;
import br.com.tasknoteapp.java_api.entity.UserEntity;
import java.util.Optional;
import org.springframework.security.core.userdetails.User;
public interface AuthService {
/**
* Create a new user in the app.
*
* @param login User details with email and password.
* @return Token
*/
public String create(LoginRequest login);
/**
* Find a user by email in the database.
*
* @param email The user email.
* @return Optional of a UserEntity instance.
*/
public Optional<UserEntity> findByEmail(String email);
/**
* Load a user from the database given his email.
*
* @param email The user email.
* @return User with found record.
*/
public User loadUserByUsername(String email);
/**
* SignIn a user given his email and password.
*
* @param login User details with email and password.
* @return Token
*/
public String signin(LoginRequest login);
}
@@ -0,0 +1,20 @@
package br.com.tasknoteapp.java_api.service;
import java.time.LocalDateTime;
import java.util.Map;
import org.springframework.security.core.userdetails.UserDetails;
public interface JwtService {
public String getEmailFromToken(String token);
public LocalDateTime extractExpiration(String token);
public String generateToken(String username);
public String createToken(Map<String, Object> claims, String email);
public boolean isTokenExpired(String token);
public boolean validateTokenAndUser(String token, UserDetails user);
}
@@ -0,0 +1,7 @@
package br.com.tasknoteapp.java_api.service;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserService {
UserDetailsService userDetailsService();
}
@@ -0,0 +1,111 @@
package br.com.tasknoteapp.java_api.service.impl;
import br.com.tasknoteapp.java_api.auth.LoginRequest;
import br.com.tasknoteapp.java_api.entity.UserEntity;
import br.com.tasknoteapp.java_api.exception.UserAlreadyExistsException;
import br.com.tasknoteapp.java_api.exception.UserNotFoundException;
import br.com.tasknoteapp.java_api.repository.UserRepository;
import br.com.tasknoteapp.java_api.service.AuthService;
import br.com.tasknoteapp.java_api.service.JwtService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@AllArgsConstructor
class AuthServiceImpl implements AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
/**
* Create a new user in the app.
*
* @param login User details with email and password.
* @return Token
*/
@Override
public String create(LoginRequest login) {
log.info("Creating user! {}", login.email());
if (findByEmail(login.email()).isPresent()) {
throw new UserAlreadyExistsException();
}
UserEntity user = new UserEntity();
user.setEmail(login.email());
user.setPassword(passwordEncoder.encode(login.password()));
user.setAdmin(login.email().equals("ricardompcampos@gmail.com"));
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
String token = jwtService.generateToken(user.getEmail());
log.info("User created! Token {}", token);
return token;
}
/**
* Find a user by email in the database.
*
* @param email The user email.
* @return Optional of a UserEntity instance.
*/
@Override
public Optional<UserEntity> findByEmail(String email) {
return userRepository.findByEmail(email);
}
/**
* Load a user from the database given his email.
*
* @param email The user email.
* @return User with found record.
*/
@Override
public User loadUserByUsername(String email) {
Optional<UserEntity> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new UserNotFoundException();
}
return new User(user.get().getEmail(), user.get().getPassword(), new ArrayList<>());
}
/**
* SignIn a user given his email and password.
*
* @param login User details with email and password.
* @return Token
*/
@Override
public String signin(LoginRequest login) {
log.info("Creating user! {}", login.email());
Optional<UserEntity> user = findByEmail(login.email());
if (user.isEmpty()) {
throw new UserNotFoundException();
}
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(login.email(), login.password()));
String token = jwtService.generateToken(user.get().getEmail());
log.info("User authenticated! Token {}", token);
return token;
}
}
@@ -0,0 +1,70 @@
package br.com.tasknoteapp.java_api.service.impl;
import br.com.tasknoteapp.java_api.service.JwtService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import javax.crypto.SecretKey;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
@Service
public class JwtServiceImpl implements JwtService {
private final long SECOND = 1000;
private final long MINUTE = SECOND * 60;
private final long EXPIRATION_TIME = MINUTE * 30;
private final SecretKey KEY = Jwts.SIG.HS256.key().build();
@Override
public String getEmailFromToken(String token) {
return extractClaim(token, Claims::getSubject);
}
@Override
public LocalDateTime extractExpiration(String token) {
Date date = extractClaim(token, Claims::getExpiration);
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
}
@Override
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
@Override
public String createToken(Map<String, Object> claims, String email) {
return Jwts.builder()
.issuer("Java-API")
.subject(email)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(KEY)
.compact();
}
@Override
public boolean isTokenExpired(String token) {
return extractExpiration(token).isBefore(LocalDateTime.now());
}
@Override
public boolean validateTokenAndUser(String token, UserDetails user) {
final String email = user.getUsername();
return !isTokenExpired(token) && getEmailFromToken(token).equals(email);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().verifyWith(KEY).build().parseSignedClaims(token).getPayload();
}
}
@@ -0,0 +1,32 @@
package br.com.tasknoteapp.java_api.service.impl;
import br.com.tasknoteapp.java_api.entity.UserEntity;
import br.com.tasknoteapp.java_api.repository.UserRepository;
import br.com.tasknoteapp.java_api.service.UserService;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Override
public UserDetailsService userDetailsService() {
return new UserDetailsService() {
@Override
public UserDetails loadUserByUsername(String email) {
Optional<UserEntity> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new RuntimeException("User not found: " + email);
}
return user.get();
}
};
}
}
@@ -0,0 +1,12 @@
CREATE SCHEMA IF NOT EXISTS tasknote;
CREATE TABLE IF NOT EXISTS tasknote.users (
id SERIAL,
email VARCHAR(100) NOT NULL,
password VARCHAR(255) NOT NULL,
admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
inactivated_at TIMESTAMP,
CONSTRAINT users_pk PRIMARY KEY (id),
CONSTRAINT users_email_uk UNIQUE (email)
);